diff --git a/etc/bot-checkstyle.xml b/etc/bot-checkstyle.xml index da9b3f275..963f7ccf5 100644 --- a/etc/bot-checkstyle.xml +++ b/etc/bot-checkstyle.xml @@ -86,6 +86,9 @@ + + + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ActivityHandler.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ActivityHandler.java index 60987f678..58b32a75f 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ActivityHandler.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ActivityHandler.java @@ -87,6 +87,9 @@ public CompletableFuture onTurn(TurnContext turnContext) { case ActivityTypes.INSTALLATION_UPDATE: return onInstallationUpdate(turnContext); + case ActivityTypes.END_OF_CONVERSATION: + return onEndOfConversationActivity(turnContext); + case ActivityTypes.TYPING: return onTypingActivity(turnContext); @@ -565,6 +568,17 @@ protected CompletableFuture onInstallationUpdateRemove(TurnContext turnCon return CompletableFuture.completedFuture(null); } + /** + * Override this in a derived class to provide logic specific to + * ActivityTypes.END_OF_CONVERSATION activities. + * + * @param turnContext The context object for this turn. + * @return A task that represents the work queued to execute. + */ + protected CompletableFuture onEndOfConversationActivity(TurnContext turnContext) { + return CompletableFuture.completedFuture(null); + } + /** * Override this in a derived class to provide logic specific to * ActivityTypes.Typing activities. diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java index 60e0e8f44..eb8ed6491 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/BotFrameworkAdapter.java @@ -26,6 +26,7 @@ import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; import com.microsoft.bot.connector.authentication.MicrosoftGovernmentAppCredentials; import com.microsoft.bot.connector.authentication.SimpleCredentialProvider; +import com.microsoft.bot.connector.authentication.SkillValidation; import com.microsoft.bot.connector.rest.RestConnectorClient; import com.microsoft.bot.connector.rest.RestOAuthClient; import com.microsoft.bot.schema.AadResourceUrls; @@ -438,7 +439,10 @@ public CompletableFuture processActivity(ClaimsIdentity identity // The OAuthScope is also stored on the TurnState to get the correct // AppCredentials if fetching a token is required. - String scope = getBotFrameworkOAuthScope(); + String scope = SkillValidation.isSkillClaim(identity.claims()) + ? String.format("%s/.default", JwtTokenValidation.getAppIdFromClaims(identity.claims())) + : getBotFrameworkOAuthScope(); + context.getTurnState().add(OAUTH_SCOPE_KEY, scope); pipelineResult = createConnectorClient(activity.getServiceUrl(), identity, scope) @@ -493,6 +497,13 @@ private CompletableFuture generateCallerId(ClaimsIdentity claimsIdentity return null; } + // Is the activity from another bot? + if (SkillValidation.isSkillClaim(claimsIdentity.claims())) { + return String.format("%s%s", + CallerIdConstants.BOT_TO_BOT_PREFIX, + JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims())); + } + // Is the activity from Public Azure? if (channelProvider == null || channelProvider.isPublicAzure()) { return CallerIdConstants.PUBLIC_AZURE_CHANNEL; @@ -1133,7 +1144,13 @@ public CompletableFuture createConnectorClient(String serviceUr } if (botAppIdClaim != null) { - String scope = getBotFrameworkOAuthScope(); + String scope = audience; + + if (StringUtils.isBlank(audience)) { + scope = SkillValidation.isSkillClaim(claimsIdentity.claims()) + ? String.format("%s/.default", JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims())) + : getBotFrameworkOAuthScope(); + } return getAppCredentials(botAppIdClaim, scope) .thenCompose(credentials -> getOrCreateConnectorClient(serviceUrl, credentials)); @@ -1236,8 +1253,8 @@ private CompletableFuture getAppCredentials(String appId, String protected CompletableFuture buildAppCredentials(String appId, String scope) { return credentialProvider.getAppPassword(appId).thenApply(appPassword -> { AppCredentials credentials = channelProvider != null && channelProvider.isGovernment() - ? new MicrosoftGovernmentAppCredentials(appId, appPassword, scope) - : new MicrosoftAppCredentials(appId, appPassword); + ? new MicrosoftGovernmentAppCredentials(appId, appPassword, null, scope) + : new MicrosoftAppCredentials(appId, appPassword, null, scope); return credentials; }); } @@ -1368,12 +1385,13 @@ public CompletableFuture getUserToken(TurnContext context, AppCre )); } - OAuthClient client = createOAuthAPIClient(context, oAuthAppCredentials).join(); - return client.getUserToken().getToken( + return createOAuthAPIClient(context, oAuthAppCredentials).thenCompose(client -> { + return client.getUserToken().getToken( context.getActivity().getFrom().getId(), connectionName, context.getActivity().getChannelId(), magicCode); + }); } /** diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ChannelServiceHandler.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ChannelServiceHandler.java new file mode 100644 index 000000000..cb3e74ddd --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ChannelServiceHandler.java @@ -0,0 +1,632 @@ +package com.microsoft.bot.builder; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.connector.authentication.AuthenticationException; +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.AttachmentData; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationParameters; +import com.microsoft.bot.schema.ConversationResourceResponse; +import com.microsoft.bot.schema.ConversationsResult; +import com.microsoft.bot.schema.PagedMembersResult; +import com.microsoft.bot.schema.ResourceResponse; +import com.microsoft.bot.schema.Transcript; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.StringUtils; + +/** + * A class to help with the implementation of the Bot Framework protocol. + */ +public class ChannelServiceHandler { + + private ChannelProvider channelProvider; + + private final AuthenticationConfiguration authConfiguration; + private final CredentialProvider credentialProvider; + + /** + * Initializes a new instance of the {@link ChannelServiceHandler} class, + * using a credential provider. + * + * @param credentialProvider The credential provider. + * @param authConfiguration The authentication configuration. + * @param channelProvider The channel provider. + */ + public ChannelServiceHandler( + CredentialProvider credentialProvider, + AuthenticationConfiguration authConfiguration, + ChannelProvider channelProvider) { + + if (credentialProvider == null) { + throw new IllegalArgumentException("credentialprovider cannot be nul"); + } + + if (authConfiguration == null) { + throw new IllegalArgumentException("authConfiguration cannot be nul"); + } + + this.credentialProvider = credentialProvider; + this.authConfiguration = authConfiguration; + this.channelProvider = channelProvider; + } + + /** + * Sends an activity to the end of a conversation. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param activity The activity to send. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleSendToConversation( + String authHeader, + String conversationId, + Activity activity) { + + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onSendToConversation(claimsIdentity, conversationId, activity); + }); + } + + /** + * Sends a reply to an activity. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param activityId The activity Id the reply is to. + * @param activity The activity to send. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleReplyToActivity( + String authHeader, + String conversationId, + String activityId, + Activity activity) { + + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onReplyToActivity(claimsIdentity, conversationId, activityId, activity); + }); + } + + /** + * Edits a previously sent existing activity. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param activityId The activity Id to update. + * @param activity The replacement activity. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleUpdateActivity( + String authHeader, + String conversationId, + String activityId, + Activity activity) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onUpdateActivity(claimsIdentity, conversationId, activityId, activity); + }); + } + + /** + * Deletes an existing activity. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param activityId The activity Id. + * + * @return A {@link CompletableFuture} representing the result of + * the asynchronous operation. + */ + public CompletableFuture handleDeleteActivity(String authHeader, String conversationId, String activityId) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onDeleteActivity(claimsIdentity, conversationId, activityId); + }); + } + + /** + * Enumerates the members of an activity. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param activityId The activity Id. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture> handleGetActivityMembers( + String authHeader, + String conversationId, + String activityId) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onGetActivityMembers(claimsIdentity, conversationId, activityId); + }); + } + + /** + * Create a new Conversation. + * + * @param authHeader The authentication header. + * @param parameters Parameters to create the conversation from. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleCreateConversation( + String authHeader, + ConversationParameters parameters) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onCreateConversation(claimsIdentity, parameters); + }); + } + + /** + * Lists the Conversations in which the bot has participated. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param continuationToken A skip or continuation token. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleGetConversations( + String authHeader, + String conversationId, + String continuationToken) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onGetConversations(claimsIdentity, conversationId, continuationToken); + }); + } + + /** + * Enumerates the members of a conversation. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture> handleGetConversationMembers( + String authHeader, + String conversationId) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onGetConversationMembers(claimsIdentity, conversationId); + }); + } + + /** + * Enumerates the members of a conversation one page at a time. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param pageSize Suggested page size. + * @param continuationToken A continuation token. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleGetConversationPagedMembers( + String authHeader, + String conversationId, + Integer pageSize, + String continuationToken) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onGetConversationPagedMembers(claimsIdentity, conversationId, pageSize, continuationToken); + }); + } + + /** + * Deletes a member from a conversation. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param memberId Id of the member to delete from this + * conversation. + * + * @return A {@link CompletableFuture} representing the + * asynchronous operation. + */ + public CompletableFuture handleDeleteConversationMember( + String authHeader, + String conversationId, + String memberId) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onDeleteConversationMember(claimsIdentity, conversationId, memberId); + }); + } + + /** + * Uploads the historic activities of the conversation. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param transcript Transcript of activities. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleSendConversationHistory( + String authHeader, + String conversationId, + Transcript transcript) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onSendConversationHistory(claimsIdentity, conversationId, transcript); + }); + } + + /** + * Stores data in a compliant store when dealing with enterprises. + * + * @param authHeader The authentication header. + * @param conversationId The conversation Id. + * @param attachmentUpload Attachment data. + * + * @return A {@link CompletableFuture{TResult}} representing the + * result of the asynchronous operation. + */ + public CompletableFuture handleUploadAttachment( + String authHeader, + String conversationId, + AttachmentData attachmentUpload) { + return authenticate(authHeader).thenCompose(claimsIdentity -> { + return onUploadAttachment(claimsIdentity, conversationId, attachmentUpload); + }); + } + + /** + * SendToConversation() API for Skill. + * + * This method allows you to send an activity to the end of a conversation. + * This is slightly different from ReplyToActivity(). * + * SendToConversation(conversationId) - will append the activity to the end + * of the conversation according to the timestamp or semantics of the + * channel. * ReplyToActivity(conversationId,ActivityId) - adds the + * activity as a reply to another activity, if the channel supports it. If + * the channel does not support nested replies, ReplyToActivity falls back + * to SendToConversation. Use ReplyToActivity when replying to a specific + * activity in the conversation. Use SendToConversation in all other cases. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId conversationId. + * @param activity Activity to send. + * + * @return task for a resource response. + */ + protected CompletableFuture onSendToConversation( + ClaimsIdentity claimsIdentity, + String conversationId, + Activity activity) { + throw new NotImplementedException("onSendToConversation is not implemented"); + } + + /** + * OnReplyToActivity() API. + * + * Override this method allows to reply to an Activity. This is slightly + * different from SendToConversation(). * + * SendToConversation(conversationId) - will append the activity to the end + * of the conversation according to the timestamp or semantics of the + * channel. * ReplyToActivity(conversationId,ActivityId) - adds the + * activity as a reply to another activity, if the channel supports it. If + * the channel does not support nested replies, ReplyToActivity falls back + * to SendToConversation. Use ReplyToActivity when replying to a specific + * activity in the conversation. Use SendToConversation in all other cases. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param activityId activityId the reply is to (OPTONAL). + * @param activity Activity to send. + * + * @return task for a resource response. + */ + protected CompletableFuture onReplyToActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId, + Activity activity) { + throw new NotImplementedException("onReplyToActivity is not implemented"); + } + + /** + * OnUpdateActivity() API. + * + * Override this method to edit a previously sent 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 D. + * @param activityId activityId to update. + * @param activity replacement Activity. + * + * @return task for a resource response. + */ + protected CompletableFuture onUpdateActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId, + Activity activity) { + throw new NotImplementedException("onUpdateActivity is not implemented"); + } + + /** + * OnDeleteActivity() API. + * + * Override this method to 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 D. + * @param activityId activityId to delete. + * + * @return task for a resource response. + */ + protected CompletableFuture onDeleteActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId) { + throw new NotImplementedException("onDeleteActivity is not implemented"); + } + + /** + * OnGetActivityMembers() API. + * + * Override this method to enumerate the members of an activity. This REST + * API takes a ConversationId and a ActivityId, returning an array of + * ChannelAccount Objects representing the members of the particular + * activity in the conversation. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param activityId Activity D. + * + * @return task with result. + */ + protected CompletableFuture> onGetActivityMembers( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId) { + throw new NotImplementedException("onGetActivityMembers is not implemented"); + } + + /** + * CreateConversation() API. + * + * Override this method to create a new Conversation. POST to this method + * with a * Bot being the bot creating the conversation * IsGroup set to + * true if this is not a direct message (default instanceof false) * Array + * containing the members to include in the conversation The return value + * is a ResourceResponse which contains a conversation D which is suitable + * for use in the message payload and REST API URIs. Most channels only + * support the semantics of bots initiating a direct message conversation. + * An example of how to do that would be: var resource = + * connector.getconversations().CreateConversation(new + * ConversationParameters(){ Bot = bot, members = new ChannelAccount[] { + * new ChannelAccount("user1") } ); + * connect.getConversations().OnSendToConversation(resource.getId(), new + * Activity() ... ) ; end. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param parameters Parameters to create the conversation + * from. + * + * @return task for a conversation resource response. + */ + protected CompletableFuture onCreateConversation( + ClaimsIdentity claimsIdentity, + ConversationParameters parameters) { + throw new NotImplementedException("onCreateConversation is not implemented"); + } + + /** + * OnGetConversations() API for Skill. + * + * Override this method to list the Conversations in which this bot has + * participated. GET from this method with a skip token The return value is + * a ConversationsResult, which contains an array of ConversationMembers + * and a skip token. If the skip token is not empty, then there are further + * values to be returned. Call this method again with the returned token to + * get more values. Each ConversationMembers Object contains the D of the + * conversation and an array of ChannelAccounts that describe the members + * of the conversation. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId conversationId. + * @param continuationToken skip or continuation token. + * + * @return task for ConversationsResult. + */ + protected CompletableFuture onGetConversations( + ClaimsIdentity claimsIdentity, + String conversationId, + String continuationToken) { + throw new NotImplementedException("onGetConversationMembers is not implemented"); + } + + /** + * GetConversationMembers() API for Skill. + * + * Override this method to enumerate the members of a conversation. This + * REST API takes a ConversationId and returns an array of ChannelAccount + * Objects representing the members of the conversation. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * + * @return task for a response. + */ + protected CompletableFuture> onGetConversationMembers( + ClaimsIdentity claimsIdentity, + String conversationId) { + throw new NotImplementedException("onGetConversationMembers is not implemented"); + } + + /** + * GetConversationPagedMembers() API for Skill. + * + * Override this method to enumerate the members of a conversation one page + * at a time. This REST API takes a ConversationId. Optionally a pageSize + * and/or continuationToken can be provided. It returns a + * PagedMembersResult, which contains an array of ChannelAccounts + * representing the members of the conversation and a continuation token + * that can be used to get more values. One page of ChannelAccounts records + * are returned with each call. The number of records in a page may vary + * between channels and calls. The pageSize parameter can be used as a + * suggestion. If there are no additional results the response will not + * contain a continuation token. If there are no members in the + * conversation the Members will be empty or not present in the response. A + * response to a request that has a continuation token from a prior request + * may rarely return members from a previous request. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param pageSize Suggested page size. + * @param continuationToken Continuation Token. + * + * @return task for a response. + */ + protected CompletableFuture onGetConversationPagedMembers( + ClaimsIdentity claimsIdentity, + String conversationId, + Integer pageSize, + String continuationToken) { + throw new NotImplementedException("onGetConversationPagedMembers is not implemented"); + } + + /** + * DeleteConversationMember() API for Skill. + * + * Override this method to deletes a member from a conversation. This REST + * API takes a ConversationId and a memberId (of type String) and removes + * that member from the conversation. If that member was the last member of + * the conversation, the conversation will also be deleted. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param memberId D of the member to delete from this + * conversation. + * + * @return task. + */ + protected CompletableFuture onDeleteConversationMember( + ClaimsIdentity claimsIdentity, + String conversationId, + String memberId) { + throw new NotImplementedException("onDeleteConversationMember is not implemented"); + } + + /** + * SendConversationHistory() API for Skill. + * + * Override this method to this method allows you to upload the historic + * activities to the conversation. Sender must ensure that the historic + * activities have unique ids and appropriate timestamps. The ids are used + * by the client to deal with duplicate activities and the timestamps are + * used by the client to render the activities in the right order. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param transcript Transcript of activities. + * + * @return task for a resource response. + */ + protected CompletableFuture onSendConversationHistory( + ClaimsIdentity claimsIdentity, + String conversationId, + Transcript transcript) { + throw new NotImplementedException("onSendConversationHistory is not implemented"); + } + + /** + * UploadAttachment() API. + * + * Override this method to store data in a compliant store when dealing + * with enterprises. The response is a ResourceResponse which contains an + * AttachmentId which is suitable for using with the attachments API. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation D. + * @param attachmentUpload Attachment data. + * + * @return task with result. + */ + protected CompletableFuture onUploadAttachment( + ClaimsIdentity claimsIdentity, + String conversationId, + AttachmentData attachmentUpload) { + throw new NotImplementedException("onUploadAttachment is not implemented"); + } + + /** + * Helper to authenticate the header. + * + * This code is very similar to the code in + * {@link JwtTokenValidation#authenticateRequest(Activity, String, + * CredentialProvider, ChannelProvider, AuthenticationConfiguration, + * HttpClient)} , we should move this code somewhere in that library when + * we refactor auth, for now we keep it private to avoid adding more public + * static functions that we will need to deprecate later. + */ + private CompletableFuture authenticate(String authHeader) { + if (StringUtils.isEmpty(authHeader)) { + return credentialProvider.isAuthenticationDisabled().thenCompose(isAuthDisabled -> { + if (!isAuthDisabled) { + return Async.completeExceptionally( + // No auth header. Auth is required. Request is not authorized. + new AuthenticationException("No auth header, Auth is required. Request is not authorized") + ); + } + + // In the scenario where auth is disabled, we still want to have the + // IsAuthenticated flag set in the ClaimsIdentity. + // To do this requires adding in an empty claim. + // Since ChannelServiceHandler calls are always a skill callback call, we set the skill claim too. + return CompletableFuture.completedFuture(SkillValidation.createAnonymousSkillClaim()); + }); + } + + // Validate the header and extract claims. + return JwtTokenValidation.validateAuthHeader( + authHeader, credentialProvider, getChannelProvider(), "unknown", null, authConfiguration); + } + /** + * Gets the channel provider that implements {@link ChannelProvider} . + * @return the ChannelProvider value as a getChannelProvider(). + */ + protected ChannelProvider getChannelProvider() { + return this.channelProvider; + } + +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/InvokeResponse.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/InvokeResponse.java index 7a4af8d6b..483c23de4 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/InvokeResponse.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/InvokeResponse.java @@ -10,6 +10,7 @@ * Serialized content from the Body property. */ public class InvokeResponse { + /** * The POST that is generated in response to the incoming Invoke Activity will * have the HTTP Status code specified by this field. @@ -23,7 +24,7 @@ public class InvokeResponse { /** * Initializes new instance of InvokeResponse. - * + * * @param withStatus The invoke response status. * @param withBody The invoke response body. */ @@ -34,7 +35,7 @@ public InvokeResponse(int withStatus, Object withBody) { /** * Gets the HTTP status code for the response. - * + * * @return The HTTP status code. */ public int getStatus() { @@ -43,7 +44,7 @@ public int getStatus() { /** * Sets the HTTP status code for the response. - * + * * @param withStatus The HTTP status code. */ public void setStatus(int withStatus) { @@ -52,7 +53,7 @@ public void setStatus(int withStatus) { /** * Gets the body content for the response. - * + * * @return The body content. */ public Object getBody() { @@ -61,10 +62,20 @@ public Object getBody() { /** * Sets the body content for the response. - * + * * @param withBody The body content. */ public void setBody(Object withBody) { body = withBody; } + + /** + * Returns if the status of the request was successful. + * @return True if the status code is successful, false if not. + */ + @SuppressWarnings("MagicNumber") + public boolean getIsSuccessStatusCode() { + return status >= 200 && status <= 299; + } + } diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ShowTypingMiddleware.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ShowTypingMiddleware.java index bf490d079..500bc12a2 100644 --- a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ShowTypingMiddleware.java +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/ShowTypingMiddleware.java @@ -4,6 +4,8 @@ package com.microsoft.bot.builder; import com.microsoft.bot.connector.ExecutorFactory; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.SkillValidation; import com.microsoft.bot.schema.Activity; import com.microsoft.bot.schema.ActivityTypes; import com.microsoft.bot.schema.ConversationReference; @@ -73,7 +75,7 @@ public ShowTypingMiddleware(long withDelay, long withPeriod) throws IllegalArgum */ @Override public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next) { - if (!turnContext.getActivity().isType(ActivityTypes.MESSAGE)) { + if (!turnContext.getActivity().isType(ActivityTypes.MESSAGE) || isSkillBot(turnContext)) { return next.next(); } @@ -83,6 +85,16 @@ public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next return next.next().thenAccept(result -> sendFuture.cancel(true)); } + private static Boolean isSkillBot(TurnContext turnContext) { + Object identity = turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + if (identity instanceof ClaimsIdentity) { + ClaimsIdentity claimsIdentity = (ClaimsIdentity) identity; + return SkillValidation.isSkillClaim(claimsIdentity.claims()); + } else { + return false; + } + } + private static CompletableFuture sendTyping( TurnContext turnContext, long delay, diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TypedInvokeResponse.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TypedInvokeResponse.java new file mode 100644 index 000000000..7ac36c695 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/TypedInvokeResponse.java @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder; + +/** + * Tuple class containing an HTTP Status Code and a JSON Serializable object. + * The HTTP Status code is, in the invoke activity scenario, what will be set in + * the resulting POST. The Body of the resulting POST will be the JSON + * Serialized content from the Body property. + * @param The type for the body of the TypedInvokeResponse. + */ +public class TypedInvokeResponse extends InvokeResponse { + + /** + * Initializes new instance of InvokeResponse. + * + * @param withStatus The invoke response status. + * @param withBody The invoke response body. + */ + public TypedInvokeResponse(int withStatus, T withBody) { + super(withStatus, withBody); + } + + /** + * Sets the body with a typed value. + * @param withBody the typed value to set the body to. + */ + public void setTypedBody(T withBody) { + super.setBody(withBody); + } + + /** + * Gets the body content for the response. + * + * @return The body content. + */ + public T getTypedBody() { + return (T) super.getBody(); + } +} diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkClient.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkClient.java new file mode 100644 index 000000000..0db9994bd --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkClient.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.TypedInvokeResponse; +import com.microsoft.bot.schema.Activity; + +/** + * A Bot Framework client. + */ +public abstract class BotFrameworkClient { + + // /** + // * Forwards an activity to a skill (bot). + // * + // * NOTE: Forwarding an activity to a skill will flush UserState and + // * ConversationState changes so that skill has accurate state. + // * + // * @param fromBotId The MicrosoftAppId of the bot sending the + // * activity. + // * @param toBotId The MicrosoftAppId of the bot receiving + // * the activity. + // * @param toUrl The URL of the bot receiving the activity. + // * @param serviceUrl The callback Url for the skill host. + // * @param conversationId A conversation ID to use for the + // * conversation with the skill. + // * @param activity The {@link Activity} to send to forward. + // * + // * @return task with optional invokeResponse. + // */ + // public abstract CompletableFuture postActivity( + // String fromBotId, + // String toBotId, + // URI toUrl, + // URI serviceUrl, + // String conversationId, + // Activity activity); + + /** + * Forwards an activity to a skill (bot). + * + * NOTE: Forwarding an activity to a skill will flush UserState and + * ConversationState changes so that skill has accurate state. + * + * @param fromBotId The MicrosoftAppId of the bot sending the + * activity. + * @param toBotId The MicrosoftAppId of the bot receiving + * the activity. + * @param toUri The URL of the bot receiving the activity. + * @param serviceUri The callback Url for the skill host. + * @param conversationId A conversation ID to use for the + * conversation with the skill. + * @param activity The {@link Activity} to send to forward. + * @param type The type for the response body to contain, can't really use due to type erasure + * in Java. + * @param The type for the TypedInvokeResponse body to contain. + * + * @return task with optional invokeResponse. + */ + public abstract CompletableFuture> postActivity( + String fromBotId, + String toBotId, + URI toUri, + URI serviceUri, + String conversationId, + Activity activity, + Class type); +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkSkill.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkSkill.java new file mode 100644 index 000000000..99f4f97f1 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/BotFrameworkSkill.java @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import java.net.URI; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Registration for a BotFrameworkHttpProtocol super. Skill endpoint. + */ +public class BotFrameworkSkill { + + @JsonProperty(value = "id") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String id; + + @JsonProperty(value = "appId") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String appId; + + @JsonProperty(value = "skillEndpoint") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private URI skillEndpoint; + + /** + * Gets Id of the skill. + * @return the Id value as a String. + */ + public String getId() { + return this.id; + } + + /** + * Sets Id of the skill. + * @param withId The Id value. + */ + public void setId(String withId) { + this.id = withId; + } + /** + * Gets appId of the skill. + * @return the AppId value as a String. + */ + public String getAppId() { + return this.appId; + } + + /** + * Sets appId of the skill. + * @param withAppId The AppId value. + */ + public void setAppId(String withAppId) { + this.appId = withAppId; + } + /** + * Gets /api/messages endpoint for the skill. + * @return the SkillEndpoint value as a Uri. + */ + public URI getSkillEndpoint() { + return this.skillEndpoint; + } + + /** + * Sets /api/messages endpoint for the skill. + * @param withSkillEndpoint The SkillEndpoint value. + */ + public void setSkillEndpoint(URI withSkillEndpoint) { + this.skillEndpoint = withSkillEndpoint; + } +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryBase.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryBase.java new file mode 100644 index 000000000..8e912ba73 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryBase.java @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.schema.ConversationReference; + +import org.apache.commons.lang3.NotImplementedException; + +/** + * Defines the interface of a factory that is used to create unique conversation + * IDs for skill conversations. + */ +public abstract class SkillConversationIdFactoryBase { + + /** + * Creates a conversation ID for a skill conversation super. on the + * caller's {@link ConversationReference} . + * + * @param conversationReference The skill's caller {@link ConversationReference} . + * + * @return A unique conversation ID used to communicate with the + * skill. + * + * It should be possible to use the returned String on a request URL and it + * should not contain special characters. + */ + public CompletableFuture createSkillConversationId(ConversationReference conversationReference) { + throw new NotImplementedException("createSkillConversationId"); + } + + /** + * Creates a conversation id for a skill conversation. + * + * @param options A {@link SkillConversationIdFactoryOptions} + * instance containing parameters for creating the conversation ID. + * + * @return A unique conversation ID used to communicate with the skill. + * + * It should be possible to use the returned String on a request URL and it + * should not contain special characters. + */ + public CompletableFuture createSkillConversationId(SkillConversationIdFactoryOptions options) { + throw new NotImplementedException("createSkillConversationId"); + } + + /** + * Gets the {@link ConversationReference} created using + * {@link + * CreateSkillConversationId(Microsoft#getBot()#getSchema()#getConversatio + * Reference(),System#getThreading()#getCancellationToken())} for a + * skillConversationId. + * + * @param skillConversationId A skill conversationId created using {@link + * CreateSkillConversationId(Microsoft#getBot()#getSchema()#getConversatio + * Reference(),System#getThreading()#getCancellationToken())} . + * + * @return The caller's {@link ConversationReference} for a skillConversationId. null if not found. + */ + public CompletableFuture getConversationReference(String skillConversationId) { + throw new NotImplementedException("getConversationReference"); + } + + /** + * Gets the {@link SkillConversationReference} used during {@link + * CreateSkillConversationId(SkillConversationIdFactoryOptions,System#getT + * reading()#getCancellationToken())} for a skillConversationId. + * + * @param skillConversationId A skill conversationId created using {@link + * CreateSkillConversationId(SkillConversationIdFactoryOptions,System#getT + * reading()#getCancellationToken())} . + * + * @return The caller's {@link ConversationReference} for a skillConversationId, with originatingAudience. + * Null if not found. + */ + public CompletableFuture getSkillConversationReference(String skillConversationId) { + throw new NotImplementedException("getSkillConversationReference"); + } + + /** + * Deletes a {@link ConversationReference} . + * + * @param skillConversationId A skill conversationId created using {@link + * CreateSkillConversationId(SkillConversationIdFactoryOptions,System#getT + * reading()#getCancellationToken())} . + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + */ + public abstract CompletableFuture deleteConversationReference(String skillConversationId); +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryOptions.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryOptions.java new file mode 100644 index 000000000..c0ffde7ef --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationIdFactoryOptions.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import com.microsoft.bot.schema.Activity; + +/** + * A class defining the parameters used in + * {@link SkillConversationIdFactoryBase#createSkillConversationId(SkillConversationI + * FactoryOptions,System#getThreading()#getCancellationToken())} . + */ +public class SkillConversationIdFactoryOptions { + + private String fromBotOAuthScope; + + private String fromBotId; + + private Activity activity; + + private BotFrameworkSkill botFrameworkSkill; + + /** + * Gets the oauth audience scope, used during token retrieval + * (either https://api.getbotframework().com or bot app id). + * @return the FromBotOAuthScope value as a String. + */ + public String getFromBotOAuthScope() { + return this.fromBotOAuthScope; + } + + /** + * Sets the oauth audience scope, used during token retrieval + * (either https://api.getbotframework().com or bot app id). + * @param withFromBotOAuthScope The FromBotOAuthScope value. + */ + public void setFromBotOAuthScope(String withFromBotOAuthScope) { + this.fromBotOAuthScope = withFromBotOAuthScope; + } + + /** + * Gets the id of the parent bot that is messaging the skill. + * @return the FromBotId value as a String. + */ + public String getFromBotId() { + return this.fromBotId; + } + + /** + * Sets the id of the parent bot that is messaging the skill. + * @param withFromBotId The FromBotId value. + */ + public void setFromBotId(String withFromBotId) { + this.fromBotId = withFromBotId; + } + + /** + * Gets the activity which will be sent to the skill. + * @return the Activity value as a getActivity(). + */ + public Activity getActivity() { + return this.activity; + } + + /** + * Sets the activity which will be sent to the skill. + * @param withActivity The Activity value. + */ + public void setActivity(Activity withActivity) { + this.activity = withActivity; + } + /** + * Gets the skill to create the conversation Id for. + * @return the BotFrameworkSkill value as a getBotFrameworkSkill(). + */ + public BotFrameworkSkill getBotFrameworkSkill() { + return this.botFrameworkSkill; + } + + /** + * Sets the skill to create the conversation Id for. + * @param withBotFrameworkSkill The BotFrameworkSkill value. + */ + public void setBotFrameworkSkill(BotFrameworkSkill withBotFrameworkSkill) { + this.botFrameworkSkill = withBotFrameworkSkill; + } +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationReference.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationReference.java new file mode 100644 index 000000000..5e538fbe7 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillConversationReference.java @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import com.microsoft.bot.schema.ConversationReference; + +/** + * A conversation reference type for skills. + */ +public class SkillConversationReference { + + private ConversationReference conversationReference; + + private String oAuthScope; + + /** + * Gets the conversation reference. + * @return the ConversationReference value as a getConversationReference(). + */ + public ConversationReference getConversationReference() { + return this.conversationReference; + } + + /** + * Sets the conversation reference. + * @param withConversationReference The ConversationReference value. + */ + public void setConversationReference(ConversationReference withConversationReference) { + this.conversationReference = withConversationReference; + } + + /** + * Gets the OAuth scope. + * @return the OAuthScope value as a String. + */ + public String getOAuthScope() { + return this.oAuthScope; + } + + /** + * Sets the OAuth scope. + * @param withOAuthScope The OAuthScope value. + */ + public void setOAuthScope(String withOAuthScope) { + this.oAuthScope = withOAuthScope; + } + +} diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillHandler.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillHandler.java new file mode 100644 index 000000000..e2107182f --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/SkillHandler.java @@ -0,0 +1,340 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.builder.skills; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.databind.JsonNode; +import com.microsoft.bot.builder.Bot; +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.BotCallbackHandler; +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.connector.authentication.AuthenticationConstants; +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.CallerIdConstants; +import com.microsoft.bot.schema.ConversationReference; +import com.microsoft.bot.schema.ResourceResponse; + +import org.apache.commons.lang3.NotImplementedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Bot Framework Handler for skills. + */ +public class SkillHandler extends ChannelServiceHandler { + + /** + * The skill conversation reference. + */ + public static final String SKILL_CONVERSATION_REFERENCE_KEY = + "com.microsoft.bot.builder.skills.SkillConversationReference"; + + private final BotAdapter adapter; + private final Bot bot; + private final SkillConversationIdFactoryBase conversationIdFactory; + + /** + * The slf4j Logger to use. Note that slf4j is configured by providing Log4j + * dependencies in the POM, and corresponding Log4j configuration in the + * 'resources' folder. + */ + private Logger logger = LoggerFactory.getLogger(SkillHandler.class); + + /** + * Initializes a new instance of the {@link SkillHandler} class, using a + * credential provider. + * + * @param adapter An instance of the {@link BotAdapter} that will handle the request. + * @param bot The {@link IBot} instance. + * @param conversationIdFactory A {@link SkillConversationIdFactoryBase} to unpack the conversation ID and + * map it to the calling bot. + * @param credentialProvider The credential provider. + * @param authConfig The authentication configuration. + * @param channelProvider The channel provider. + * + * Use a {@link MiddlewareSet} Object to add multiple middleware components + * in the constructor. Use the Use({@link Middleware} ) method to add + * additional middleware to the adapter after construction. + */ + public SkillHandler( + BotAdapter adapter, + Bot bot, + SkillConversationIdFactoryBase conversationIdFactory, + CredentialProvider credentialProvider, + AuthenticationConfiguration authConfig, + ChannelProvider channelProvider + ) { + + super(credentialProvider, authConfig, channelProvider); + + if (adapter == null) { + throw new IllegalArgumentException("adapter cannot be null"); + } + + if (bot == null) { + throw new IllegalArgumentException("bot cannot be null"); + } + + if (conversationIdFactory == null) { + throw new IllegalArgumentException("conversationIdFactory cannot be null"); + } + + this.adapter = adapter; + this.bot = bot; + this.conversationIdFactory = conversationIdFactory; + } + + /** + * SendToConversation() API for Skill. + * + * This method allows you to send an activity to the end of a conversation. + * This is slightly different from ReplyToActivity(). * + * SendToConversation(conversationId) - will append the activity to the end + * of the conversation according to the timestamp or semantics of the + * channel. * ReplyToActivity(conversationId,ActivityId) - adds the + * activity as a reply to another activity, if the channel supports it. If + * the channel does not support nested replies, ReplyToActivity falls back + * to SendToConversation. Use ReplyToActivity when replying to a specific + * activity in the conversation. Use SendToConversation in all other cases. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId conversationId. + * @param activity Activity to send. + * + * @return task for a resource response. + */ + @Override + protected CompletableFuture onSendToConversation( + ClaimsIdentity claimsIdentity, + String conversationId, + Activity activity) { + return processActivity(claimsIdentity, conversationId, null, activity); + } + + /** + * ReplyToActivity() API for Skill. + * + * This method allows you to reply to an activity. This is slightly + * different from SendToConversation(). * + * SendToConversation(conversationId) - will append the activity to the end + * of the conversation according to the timestamp or semantics of the + * channel. * ReplyToActivity(conversationId,ActivityId) - adds the + * activity as a reply to another activity, if the channel supports it. If + * the channel does not support nested replies, ReplyToActivity falls back + * to SendToConversation. Use ReplyToActivity when replying to a specific + * activity in the conversation. Use SendToConversation in all other cases. + * + * @param claimsIdentity claimsIdentity for the bot, should have + * AudienceClaim, AppIdClaim and ServiceUrlClaim. + * @param conversationId Conversation ID. + * @param activityId activityId the reply is to (OPTIONAL). + * @param activity Activity to send. + * + * @return task for a resource response. + */ + @Override + protected CompletableFuture onReplyToActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId, + Activity activity) { + return processActivity(claimsIdentity, conversationId, activityId, activity); + } + + /** + */ + @Override + protected CompletableFuture onDeleteActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId) { + + SkillConversationReference skillConversationReference = getSkillConversationReference(conversationId).join(); + + BotCallbackHandler callback = turnContext -> { + turnContext.getTurnState().add(SKILL_CONVERSATION_REFERENCE_KEY, skillConversationReference); + return turnContext.deleteActivity(activityId); + }; + + return adapter.continueConversation(claimsIdentity, + skillConversationReference.getConversationReference(), + skillConversationReference.getOAuthScope(), + callback); + } + + /** + */ + @Override + protected CompletableFuture onUpdateActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String activityId, + Activity activity) { + SkillConversationReference skillConversationReference = getSkillConversationReference(conversationId).join(); + + AtomicReference resourceResponse = new AtomicReference(); + + BotCallbackHandler callback = turnContext -> { + turnContext.getTurnState().add(SKILL_CONVERSATION_REFERENCE_KEY, skillConversationReference); + activity.applyConversationReference(skillConversationReference.getConversationReference()); + turnContext.getActivity().setId(activityId); + String callerId = String.format("%s%s", + CallerIdConstants.BOT_TO_BOT_PREFIX, + JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims())); + turnContext.getActivity().setCallerId(callerId); + resourceResponse.set(turnContext.updateActivity(activity).join()); + return CompletableFuture.completedFuture(null); + }; + + adapter.continueConversation(claimsIdentity, + skillConversationReference.getConversationReference(), + skillConversationReference.getOAuthScope(), + callback); + + if (resourceResponse.get() != null) { + return CompletableFuture.completedFuture(resourceResponse.get()); + } else { + return CompletableFuture.completedFuture(new ResourceResponse(UUID.randomUUID().toString())); + } + } + + private static void applyEoCToTurnContextActivity(TurnContext turnContext, Activity endOfConversationActivity) { + // transform the turnContext.Activity to be the EndOfConversation. + turnContext.getActivity().setType(endOfConversationActivity.getType()); + turnContext.getActivity().setText(endOfConversationActivity.getText()); + turnContext.getActivity().setCode(endOfConversationActivity.getCode()); + + turnContext.getActivity().setReplyToId(endOfConversationActivity.getReplyToId()); + turnContext.getActivity().setValue(endOfConversationActivity.getValue()); + turnContext.getActivity().setEntities(endOfConversationActivity.getEntities()); + turnContext.getActivity().setLocale(endOfConversationActivity.getLocale()); + turnContext.getActivity().setLocalTimestamp(endOfConversationActivity.getLocalTimestamp()); + turnContext.getActivity().setTimestamp(endOfConversationActivity.getTimestamp()); + turnContext.getActivity().setChannelData(endOfConversationActivity.getChannelData()); + for (Map.Entry entry : endOfConversationActivity.getProperties().entrySet()) { + turnContext.getActivity().setProperties(entry.getKey(), entry.getValue()); + } + } + + private static void applyEventToTurnContextActivity(TurnContext turnContext, Activity eventActivity) { + // transform the turnContext.Activity to be the EventActivity. + turnContext.getActivity().setType(eventActivity.getType()); + turnContext.getActivity().setName(eventActivity.getName()); + turnContext.getActivity().setValue(eventActivity.getValue()); + turnContext.getActivity().setRelatesTo(eventActivity.getRelatesTo()); + + turnContext.getActivity().setReplyToId(eventActivity.getReplyToId()); + turnContext.getActivity().setValue(eventActivity.getValue()); + turnContext.getActivity().setEntities(eventActivity.getEntities()); + turnContext.getActivity().setLocale(eventActivity.getLocale()); + turnContext.getActivity().setLocalTimestamp(eventActivity.getLocalTimestamp()); + turnContext.getActivity().setTimestamp(eventActivity.getTimestamp()); + turnContext.getActivity().setChannelData(eventActivity.getChannelData()); + for (Map.Entry entry : eventActivity.getProperties().entrySet()) { + turnContext.getActivity().setProperties(entry.getKey(), entry.getValue()); + } + } + + private CompletableFuture getSkillConversationReference(String conversationId) { + + SkillConversationReference skillConversationReference; + try { + skillConversationReference = conversationIdFactory.getSkillConversationReference(conversationId).join(); + } catch (NotImplementedException ex) { + if (logger != null) { + logger.warn("Got NotImplementedException when trying to call " + + "GetSkillConversationReference() on the ConversationIdFactory," + + " attempting to use deprecated GetConversationReference() method instead."); + } + + // Attempt to get SkillConversationReference using deprecated method. + // this catch should be removed once we remove the deprecated method. + // We need to use the deprecated method for backward compatibility. + ConversationReference conversationReference = + conversationIdFactory.getConversationReference(conversationId).join(); + skillConversationReference = new SkillConversationReference(); + skillConversationReference.setConversationReference(conversationReference); + if (getChannelProvider() != null && getChannelProvider().isGovernment()) { + skillConversationReference.setOAuthScope( + GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE); + } else { + skillConversationReference.setOAuthScope( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE); + } + } + + if (skillConversationReference == null) { + if (logger != null) { + logger.warn( + String.format("Unable to get skill conversation reference for conversationId %s.", conversationId) + ); + } + throw new RuntimeException("Key not found"); + } + + return CompletableFuture.completedFuture(skillConversationReference); + } + + private CompletableFuture processActivity( + ClaimsIdentity claimsIdentity, + String conversationId, + String replyToActivityId, + Activity activity) { + + SkillConversationReference skillConversationReference = getSkillConversationReference(conversationId).join(); + + AtomicReference resourceResponse = new AtomicReference(); + + BotCallbackHandler callback = turnContext -> { + turnContext.getTurnState().add(SKILL_CONVERSATION_REFERENCE_KEY, skillConversationReference); + activity.applyConversationReference(skillConversationReference.getConversationReference()); + turnContext.getActivity().setId(replyToActivityId); + String callerId = String.format("%s%s", + CallerIdConstants.BOT_TO_BOT_PREFIX, + JwtTokenValidation.getAppIdFromClaims(claimsIdentity.claims())); + turnContext.getActivity().setCallerId(callerId); + + switch (activity.getType()) { + case ActivityTypes.END_OF_CONVERSATION: + conversationIdFactory.deleteConversationReference(conversationId).join(); + applyEoCToTurnContextActivity(turnContext, activity); + bot.onTurn(turnContext).join(); + break; + case ActivityTypes.EVENT: + applyEventToTurnContextActivity(turnContext, activity); + bot.onTurn(turnContext).join(); + break; + default: + resourceResponse.set(turnContext.sendActivity(activity).join()); + break; + } + return CompletableFuture.completedFuture(null); + }; + + adapter.continueConversation(claimsIdentity, + skillConversationReference.getConversationReference(), + skillConversationReference.getOAuthScope(), + callback).join(); + + if (resourceResponse.get() != null) { + return CompletableFuture.completedFuture(resourceResponse.get()); + } else { + return CompletableFuture.completedFuture(new ResourceResponse(UUID.randomUUID().toString())); + } + } +} + diff --git a/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/package-info.java b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/package-info.java new file mode 100644 index 000000000..4bf1c6ad6 --- /dev/null +++ b/libraries/bot-builder/src/main/java/com/microsoft/bot/builder/skills/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. + +/** + * This package contains the classes for Bot-Builder. + */ +package com.microsoft.bot.builder.skills; diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java index 2226517c6..6137eb730 100644 --- a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java @@ -9,7 +9,7 @@ import com.microsoft.bot.connector.authentication.AppCredentials; import com.microsoft.bot.schema.*; import org.apache.commons.lang3.StringUtils; - +import org.junit.rules.ExpectedException; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -271,7 +271,7 @@ public CompletableFuture sendActivities(TurnContext context, Thread.sleep(delayMs); } catch (InterruptedException e) { } - } else if (activity.getType() == ActivityTypes.TRACE) { + } else if (activity.getType().equals(ActivityTypes.TRACE)) { if (sendTraceActivity) { synchronized (botReplies) { botReplies.add(activity); @@ -506,8 +506,8 @@ public void addExchangeableToken(String connectionName, String channelId, String userId, String exchangableItem, - String token) - { + String token + ) { ExchangableTokenKey key = new ExchangableTokenKey(); key.setConnectionName(connectionName); key.setChannelId(channelId); @@ -521,6 +521,23 @@ public void addExchangeableToken(String connectionName, } } + public void throwOnExchangeRequest(String connectionName, + String channelId, + String userId, + String exchangableItem) { + ExchangableTokenKey key = new ExchangableTokenKey(); + key.setConnectionName(connectionName); + key.setChannelId(channelId); + key.setUserId(userId); + key.setExchangableItem(exchangableItem); + + if (exchangableToken.containsKey(key)) { + exchangableToken.replace(key, exceptionExpected); + } else { + exchangableToken.put(key, exceptionExpected); + } + } + @Override public CompletableFuture signOutUser(TurnContext turnContext, AppCredentials oAuthAppCredentials, String connectionName, String userId) { diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/AuthenticationConfiguration.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/AuthenticationConfiguration.java index 327cedc11..2754253ea 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/AuthenticationConfiguration.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/AuthenticationConfiguration.java @@ -10,12 +10,31 @@ * General configuration settings for authentication. */ public class AuthenticationConfiguration { + + private ClaimsValidator claimsValidator = null; + /** * Required endorsements for auth. - * + * * @return A List of endorsements. */ public List requiredEndorsements() { return new ArrayList(); } + + /** + * Access to the ClaimsValidator used to validate the identity claims. + * @return the ClaimsValidator value if set. + */ + public ClaimsValidator getClaimsValidator() { + return claimsValidator; + } + + /** + * Access to the ClaimsValidator used to validate the identity claims. + * @param withClaimsValidator the value to set the ClaimsValidator to. + */ + public void setClaimsValidator(ClaimsValidator withClaimsValidator) { + claimsValidator = withClaimsValidator; + } } diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/ClaimsValidator.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/ClaimsValidator.java new file mode 100644 index 000000000..42692da1a --- /dev/null +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/ClaimsValidator.java @@ -0,0 +1,20 @@ +package com.microsoft.bot.connector.authentication; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * An abstract class used to validate identity. + */ +public abstract class ClaimsValidator { + + /** + * Validates a Map of claims and should throw an exception if the + * validation fails. + * + * @param claims The Map of claims to validate. + * + * @return true if the validation is successful, false if not. + */ + public abstract CompletableFuture validateClaims(Map claims); +} diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java index 4d6398b78..f81a70ca6 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/JwtTokenValidation.java @@ -147,6 +147,40 @@ public static CompletableFuture validateAuthHeader( new IllegalArgumentException("No authHeader present. Auth is required.")); } + return authenticateToken(authHeader, credentials, channelProvider, channelId, serviceUrl, authConfig) + .thenApply(identity -> { + validateClaims(authConfig, identity.claims()); + return identity; + } + ); + } + + private static CompletableFuture validateClaims( + AuthenticationConfiguration authConfig, + Map claims + ) { + if (authConfig.getClaimsValidator() != null) { + return authConfig.getClaimsValidator().validateClaims(claims); + } else if (SkillValidation.isSkillClaim(claims)) { + return Async.completeExceptionally( + new RuntimeException("ClaimValidator is required for validation of Skill Host calls") + ); + } + return CompletableFuture.completedFuture(null); + } + + private static CompletableFuture authenticateToken( + String authHeader, + CredentialProvider credentials, + ChannelProvider channelProvider, + String channelId, + String serviceUrl, + AuthenticationConfiguration authConfig + ) { + if (SkillValidation.isSkillToken(authHeader)) { + return SkillValidation.authenticateChannelToken( + authHeader, credentials, channelProvider, channelId, authConfig); + } boolean usingEmulator = EmulatorValidation.isTokenFromEmulator(authHeader); if (usingEmulator) { return EmulatorValidation @@ -168,6 +202,7 @@ public static CompletableFuture validateAuthHeader( authHeader, credentials, channelProvider, serviceUrl, channelId, authConfig ); } + } /** diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/MicrosoftGovernmentAppCredentials.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/MicrosoftGovernmentAppCredentials.java index 619689313..88190f19b 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/MicrosoftGovernmentAppCredentials.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/MicrosoftGovernmentAppCredentials.java @@ -42,9 +42,26 @@ public MicrosoftGovernmentAppCredentials(String appId, String password, String o ); } + /** + * Initializes a new instance of the MicrosoftGovernmentAppCredentials class. + * + * @param withAppId The Microsoft app ID. + * @param withAppPassword The Microsoft app password. + * @param withChannelAuthTenant Optional. The oauth token tenant. + * @param withOAuthScope The scope for the token. + */ + public MicrosoftGovernmentAppCredentials( + String withAppId, + String withAppPassword, + String withChannelAuthTenant, + String withOAuthScope + ) { + super(withAppId, withAppPassword, withChannelAuthTenant, withOAuthScope); + } + /** * An empty set of credentials. - * + * * @return An empty Gov credentials. */ public static MicrosoftGovernmentAppCredentials empty() { diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/SkillValidation.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/SkillValidation.java index 33891fe5a..cbf1885be 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/SkillValidation.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/SkillValidation.java @@ -52,44 +52,71 @@ private SkillValidation() { true, Duration.ofMinutes(5), true); /** - * Checks if the given list of claims represents a skill. A skill claim should - * contain: An AuthenticationConstants.VersionClaim" claim. An - * AuthenticationConstants.AudienceClaim claim. An - * AuthenticationConstants.AppIdClaim claim (v1) or an a - * AuthenticationConstants.AuthorizedParty claim (v2). And the appId claim - * should be different than the audience claim. When a channel (webchat, teams, - * etc.) invokes a bot, the - * is set to - * but when a bot calls another bot, the audience claim is set to the appId of - * the bot being invoked. The protocol supports v1 and v2 tokens: For v1 tokens, - * the AuthenticationConstants.AppIdClaim is present and set to the app Id of - * the calling bot. For v2 tokens, the AuthenticationConstants.AuthorizedParty - * is present and set to the app Id of the calling bot. + * Determines if a given Auth header is from from a skill to bot or bot to skill + * request. + * + * @param authHeader Bearer Token, in the "Bearer [Long String]" Format. + * @return True, if the token was issued for a skill to bot communication. + * Otherwise, false. + */ + public static boolean isSkillToken(String authHeader) { + if (!JwtTokenValidation.isValidTokenFormat(authHeader)) { + return false; + } + + // We know is a valid token, split it and work with it: + // [0] = "Bearer" + // [1] = "[Big Long String]" + String bearerToken = authHeader.split(" ")[1]; + + // Parse token + ClaimsIdentity identity = new ClaimsIdentity(JWT.decode(bearerToken)); + + return isSkillClaim(identity.claims()); + } + + /** + * Checks if the given list of claims represents a skill. + * + * A skill claim should contain: An {@link AuthenticationConstants#versionClaim} + * claim. An {@link AuthenticationConstants#audienceClaim} claim. An + * {@link AuthenticationConstants#appIdClaim} claim (v1) or an a + * {@link AuthenticationConstants#authorizedParty} claim (v2). And the appId + * claim should be different than the audience claim. When a channel (webchat, + * teams, etc.) invokes a bot, the {@link AuthenticationConstants#audienceClaim} + * is set to {@link AuthenticationConstants#toBotFromChannelTokenIssuer} but + * when a bot calls another bot, the audience claim is set to the appId of the + * bot being invoked. The protocol supports v1 and v2 tokens: For v1 tokens, the + * {@link AuthenticationConstants#appIdClaim} is present and set to the app Id + * of the calling bot. For v2 tokens, the + * {@link AuthenticationConstants#authorizedParty} is present and set to the app + * Id of the calling bot. + * + * @param claims A list of claims. * - * @param claims A map of claims * @return True if the list of claims is a skill claim, false if is not. */ public static Boolean isSkillClaim(Map claims) { for (Map.Entry entry : claims.entrySet()) { - if (entry.getValue() == AuthenticationConstants.ANONYMOUS_SKILL_APPID - && entry.getKey() == AuthenticationConstants.APPID_CLAIM) { + if (entry.getValue() != null && entry.getValue().equals(AuthenticationConstants.ANONYMOUS_SKILL_APPID) + && entry.getKey().equals(AuthenticationConstants.APPID_CLAIM)) { return true; } } Optional> version = claims.entrySet().stream() - .filter((x) -> x.getKey() == AuthenticationConstants.VERSION_CLAIM).findFirst(); + .filter((x) -> x.getKey().equals(AuthenticationConstants.VERSION_CLAIM)).findFirst(); if (!version.isPresent()) { // Must have a version claim. return false; } Optional> audience = claims.entrySet().stream() - .filter((x) -> x.getKey() == AuthenticationConstants.AUDIENCE_CLAIM).findFirst(); + .filter((x) -> x.getKey().equals(AuthenticationConstants.AUDIENCE_CLAIM)).findFirst(); if (!audience.isPresent() - || AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER == audience.get().getValue()) { + || AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER.equals(audience.get().getValue())) { // The audience is https://api.botframework.com and not an appId. return false; } @@ -105,38 +132,50 @@ public static Boolean isSkillClaim(Map claims) { } /** - * Determines if a given Auth header is from from a skill to bot or bot to skill request. + * Validates that the incoming Auth Header is a token sent from a bot to a skill + * or from a skill to a bot. * - * @param authHeader Bearer Token, in the "Bearer [Long String]" Format. - * @return True, if the token was issued for a skill to bot communication. Otherwise, false. + * @param authHeader The raw HTTP header in the format: "Bearer + * [longString]". + * @param credentials The user defined set of valid credentials, such as the + * AppId. + * @param channelProvider The channelService value that distinguishes public + * Azure from US Government Azure. + * @param channelId The ID of the channel to validate. + * @param authConfig The authentication configuration. + * + * @return A {@link ClaimsIdentity} instance if the validation is successful. */ - public static boolean isSkillToken(String authHeader) { - if (!JwtTokenValidation.isValidTokenFormat(authHeader)) { - return false; + public static CompletableFuture authenticateChannelToken(String authHeader, + CredentialProvider credentials, ChannelProvider channelProvider, String channelId, + AuthenticationConfiguration authConfig) { + if (authConfig == null) { + return Async.completeExceptionally(new IllegalArgumentException("authConfig cannot be null.")); } - // We know is a valid token, split it and work with it: - // [0] = "Bearer" - // [1] = "[Big Long String]" - String bearerToken = authHeader.split(" ")[1]; + String openIdMetadataUrl = channelProvider != null && channelProvider.isGovernment() + ? GovernmentAuthenticationConstants.TO_BOT_FROM_EMULATOR_OPENID_METADATA_URL + : AuthenticationConstants.TO_BOT_FROM_EMULATOR_OPENID_METADATA_URL; - // Parse token - ClaimsIdentity identity = new ClaimsIdentity(JWT.decode(bearerToken)); + JwtTokenExtractor tokenExtractor = new JwtTokenExtractor(TOKENVALIDATIONPARAMETERS, openIdMetadataUrl, + AuthenticationConstants.ALLOWED_SIGNING_ALGORITHMS); - return isSkillClaim(identity.claims()); + return tokenExtractor.getIdentity(authHeader, channelId, authConfig.requiredEndorsements()) + .thenCompose(identity -> { + return validateIdentity(identity, credentials).thenCompose(result -> { + return CompletableFuture.completedFuture(identity); + }); + }); } /** * Helper to validate a skills ClaimsIdentity. * - * @param identity The ClaimsIdentity to validate. + * @param identity The ClaimsIdentity to validate. * @param credentials The CredentialProvider. * @return Nothing if success, otherwise a CompletionException */ - public static CompletableFuture validateIdentity( - ClaimsIdentity identity, - CredentialProvider credentials - ) { + public static CompletableFuture validateIdentity(ClaimsIdentity identity, CredentialProvider credentials) { if (identity == null) { // No valid identity. Not Authorized. return Async.completeExceptionally(new AuthenticationException("Invalid Identity")); @@ -148,28 +187,20 @@ public static CompletableFuture validateIdentity( } Optional> versionClaim = identity.claims().entrySet().stream() - .filter(item -> StringUtils.equals(AuthenticationConstants.VERSION_CLAIM, item.getKey())) - .findFirst(); + .filter(item -> StringUtils.equals(AuthenticationConstants.VERSION_CLAIM, item.getKey())).findFirst(); if (!versionClaim.isPresent()) { // No version claim - return Async.completeExceptionally( - new AuthenticationException( - AuthenticationConstants.VERSION_CLAIM + " claim is required on skill Tokens." - ) - ); + return Async.completeExceptionally(new AuthenticationException( + AuthenticationConstants.VERSION_CLAIM + " claim is required on skill Tokens.")); } // Look for the "aud" claim, but only if issued from the Bot Framework Optional> audienceClaim = identity.claims().entrySet().stream() - .filter(item -> StringUtils.equals(AuthenticationConstants.AUDIENCE_CLAIM, item.getKey())) - .findFirst(); + .filter(item -> StringUtils.equals(AuthenticationConstants.AUDIENCE_CLAIM, item.getKey())).findFirst(); if (!audienceClaim.isPresent() || StringUtils.isEmpty(audienceClaim.get().getValue())) { // Claim is not present or doesn't have a value. Not Authorized. - return Async.completeExceptionally( - new AuthenticationException( - AuthenticationConstants.AUDIENCE_CLAIM + " claim is required on skill Tokens." - ) - ); + return Async.completeExceptionally(new AuthenticationException( + AuthenticationConstants.AUDIENCE_CLAIM + " claim is required on skill Tokens.")); } String appId = JwtTokenValidation.getAppIdFromClaims(identity.claims()); @@ -177,28 +208,25 @@ public static CompletableFuture validateIdentity( return Async.completeExceptionally(new AuthenticationException("Invalid appId.")); } - return credentials.isValidAppId(audienceClaim.get().getValue()) - .thenApply(isValid -> { - if (!isValid) { - throw new AuthenticationException("Invalid audience."); - } - return null; - }); + return credentials.isValidAppId(audienceClaim.get().getValue()).thenApply(isValid -> { + if (!isValid) { + throw new AuthenticationException("Invalid audience."); + } + return null; + }); } /** * Creates a ClaimsIdentity for an anonymous (unauthenticated) skill. * * @return A ClaimsIdentity instance with authentication type set to - * AuthenticationConstants.AnonymousAuthType and a reserved - * AuthenticationConstants.AnonymousSkillAppId claim. + * AuthenticationConstants.AnonymousAuthType and a reserved + * AuthenticationConstants.AnonymousSkillAppId claim. */ public static ClaimsIdentity createAnonymousSkillClaim() { Map claims = new HashMap<>(); claims.put(AuthenticationConstants.APPID_CLAIM, AuthenticationConstants.ANONYMOUS_SKILL_APPID); - return new ClaimsIdentity( - AuthenticationConstants.ANONYMOUS_AUTH_TYPE, - AuthenticationConstants.ANONYMOUS_AUTH_TYPE, - claims); + return new ClaimsIdentity(AuthenticationConstants.ANONYMOUS_AUTH_TYPE, + AuthenticationConstants.ANONYMOUS_AUTH_TYPE, claims); } } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/BeginSkillDialogOptions.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/BeginSkillDialogOptions.java new file mode 100644 index 000000000..1f472a3ba --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/BeginSkillDialogOptions.java @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.dialogs; + +import com.microsoft.bot.schema.Activity; + +/** + * A class with dialog arguments for a {@link SkillDialog} . + */ +public class BeginSkillDialogOptions { + + private Activity activity; + + /** + * Gets the {@link Activity} to send to the skill. + * @return the Activity value as a getActivity(). + */ + public Activity getActivity() { + return this.activity; + } + + /** + * Sets the {@link Activity} to send to the skill. + * @param withActivity The Activity value. + */ + public void setActivity(Activity withActivity) { + this.activity = withActivity; + } + +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/ComponentDialog.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/ComponentDialog.java index 92022a2ae..a66b080fb 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/ComponentDialog.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/ComponentDialog.java @@ -10,409 +10,406 @@ import com.microsoft.bot.builder.TurnContext; import com.microsoft.bot.connector.Async; +/** + * A {@link Dialog} that is composed of other dialogs. + * + * A component dialog has an inner {@link DialogSet} and {@link DialogContext} + * ,which provides an inner dialog stack that is hidden from the parent dialog. + */ +public class ComponentDialog extends DialogContainer { + + private String initialDialogId; + /** - * A {@link Dialog} that is composed of other dialogs. - * - * A component dialog has an inner {@link DialogSet} and {@link DialogContext} ,which provides - * an inner dialog stack that is hidden from the parent dialog. + * The id for the persisted dialog state. */ - public class ComponentDialog extends DialogContainer { + public static final String PERSISTEDDIALOGSTATE = "dialogs"; - private String initialDialogId; + private boolean initialized; - /** - * The id for the persisted dialog state. - */ - public static final String PERSISTEDDIALOGSTATE = "dialogs"; + /** + * Initializes a new instance of the {@link ComponentDialog} class. + * + * @param dialogId The D to assign to the new dialog within the parent dialog + * set. + */ + public ComponentDialog(String dialogId) { + super(dialogId); + } - private boolean initialized; + /** + * Called when the dialog is started and pushed onto the parent's dialog stack. + * + * @param outerDc The parent {@link DialogContext} for the current turn of + * conversation. + * @param options Optional, initial information to pass to the dialog. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * If the task is successful, the result indicates whether the dialog is + * still active after the turn has been processed by the dialog. + */ + @Override + public CompletableFuture beginDialog(DialogContext outerDc, Object options) { - /** - * Initializes a new instance of the {@link ComponentDialog} class. - * - * @param dialogId The D to assign to the new dialog within the parent dialog - * set. - */ - public ComponentDialog(String dialogId) { - super(dialogId); + if (outerDc == null) { + return Async.completeExceptionally(new IllegalArgumentException("outerDc cannot be null.")); } - /** - * Called when the dialog is started and pushed onto the parent's dialog stack. - * - * @param outerDc The parent {@link DialogContext} for the current turn of - * conversation. - * @param options Optional, initial information to pass to the dialog. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * If the task is successful, the result indicates whether the dialog is - * still active after the turn has been processed by the dialog. - */ - @Override - public CompletableFuture beginDialog(DialogContext outerDc, Object options) { - - if (outerDc == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "outerDc cannot be null." - )); - } - - ensureInitialized(outerDc).join(); - - this.checkForVersionChange(outerDc).join(); - - DialogContext innerDc = this.createChildContext(outerDc); - DialogTurnResult turnResult = onBeginDialog(innerDc, options).join(); + return ensureInitialized(outerDc).thenCompose(ensureResult -> { + return this.checkForVersionChange(outerDc).thenCompose(checkResult -> { + DialogContext innerDc = this.createChildContext(outerDc); + return onBeginDialog(innerDc, options).thenCompose(turnResult -> { + // Check for end of inner dialog + if (turnResult.getStatus() != DialogTurnStatus.WAITING) { + // Return result to calling dialog + return endComponent(outerDc, turnResult.getResult()) + .thenCompose(result -> CompletableFuture.completedFuture(result)); + } + getTelemetryClient().trackDialogView(getId(), null, null); + // Just signal waiting + return CompletableFuture.completedFuture(END_OF_TURN); + }); + }); + }); + } - // Check for end of inner dialog - if (turnResult.getStatus() != DialogTurnStatus.WAITING) { - // Return result to calling dialog - DialogTurnResult result = endComponent(outerDc, turnResult.getResult()).join(); - return CompletableFuture.completedFuture(result); - } + /** + * Called when the dialog is _continued_, where it is the active dialog and the + * user replies with a new activity. + * + * @param outerDc The parent {@link DialogContext} for the current turn of + * conversation. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * 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. If this method is *not* + * overridden, the component dialog calls the + * {@link DialogContext#continueDialog(CancellationToken)} method on its + * inner dialog context. If the inner dialog stack is empty, the + * component dialog ends, and if a {@link DialogTurnResult#result} is + * available, the component dialog uses that as its return value. + */ + @Override + public CompletableFuture continueDialog(DialogContext outerDc) { + return ensureInitialized(outerDc).thenCompose(ensureResult -> { + return this.checkForVersionChange(outerDc).thenCompose(checkResult -> { + // Continue execution of inner dialog + DialogContext innerDc = this.createChildContext(outerDc); + return this.onContinueDialog(innerDc).thenCompose(turnResult -> { + // Check for end of inner dialog + if (turnResult.getStatus() != DialogTurnStatus.WAITING) { + // Return to calling dialog + return this.endComponent(outerDc, turnResult.getResult()) + .thenCompose(result -> CompletableFuture.completedFuture(result)); + } - getTelemetryClient().trackDialogView(getId(), null, null); + // Just signal waiting + return CompletableFuture.completedFuture(END_OF_TURN); - // Just signal waiting - return CompletableFuture.completedFuture(END_OF_TURN); - } + }); + }); - /** - * Called when the dialog is _continued_, where it is the active dialog and the - * user replies with a new activity. - * - * @param outerDc The parent {@link DialogContext} for the current turn of - * conversation. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * 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. If this method is *not* - * overridden, the component dialog calls the - * {@link DialogContext#continueDialog(CancellationToken)} method on its - * inner dialog context. If the inner dialog stack is empty, the - * component dialog ends, and if a {@link DialogTurnResult#result} is - * available, the component dialog uses that as its return value. - */ - @Override - public CompletableFuture continueDialog(DialogContext outerDc) { - ensureInitialized(outerDc).join(); - - this.checkForVersionChange(outerDc).join(); - - // Continue execution of inner dialog - DialogContext innerDc = this.createChildContext(outerDc); - DialogTurnResult turnResult = this.onContinueDialog(innerDc).join(); - - // Check for end of inner dialog - if (turnResult.getStatus() != DialogTurnStatus.WAITING) { - // Return to calling dialog - DialogTurnResult result = this.endComponent(outerDc, turnResult.getResult()).join(); - return CompletableFuture.completedFuture(result); - } + }); + } - // Just signal waiting - return CompletableFuture.completedFuture(END_OF_TURN); - } + /** + * Called when a child dialog on the parent's dialog stack completed this turn, + * returning control to this dialog component. + * + * @param outerDc The {@link DialogContext} for the current turn of + * conversation. + * @param reason Reason 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. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * If the task is successful, the result indicates whether this dialog + * is still active after this dialog turn has been processed. Generally, + * the child dialog was started with a call to + * {@link BeginDialog(DialogContext, Object)} in the parent's context. + * However, if the {@link DialogContext#replaceDialog(String, Object)} + * method is called, the logical child dialog may be different than the + * original. If this method is *not* overridden, the dialog + * automatically calls its {@link RepromptDialog(TurnContext, + * DialogInstance)} when the user replies. + */ + @Override + public CompletableFuture resumeDialog(DialogContext outerDc, DialogReason reason, Object result) { + return ensureInitialized(outerDc).thenCompose(ensureResult -> { + return this.checkForVersionChange(outerDc).thenCompose(versionCheckResult -> { + // Containers are typically leaf nodes on the stack but the dev is free to push + // other dialogs + // on top of the stack which will result in the container receiving an + // unexpected call to + // dialogResume() when the pushed on dialog ends. + // To avoid the container prematurely ending we need to implement this method + // and simply + // ask our inner dialog stack to re-prompt. + return repromptDialog(outerDc.getContext(), outerDc.getActiveDialog()).thenCompose(repromptResult -> { + return CompletableFuture.completedFuture(END_OF_TURN); + }); + }); + }); + } - /** - * Called when a child dialog on the parent's dialog stack completed this turn, - * returning control to this dialog component. - * - * @param outerDc The {@link DialogContext} for the current turn of - * conversation. - * @param reason Reason 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. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * If the task is successful, the result indicates whether this dialog - * is still active after this dialog turn has been processed. Generally, - * the child dialog was started with a call to - * {@link BeginDialog(DialogContext, Object)} in the parent's context. - * However, if the {@link DialogContext#replaceDialog(String, Object)} - * method is called, the logical child dialog may be different than the - * original. If this method is *not* overridden, the dialog - * automatically calls its {@link RepromptDialog(TurnContext, - * DialogInstance)} when the user replies. - */ - @Override - public CompletableFuture resumeDialog(DialogContext outerDc, DialogReason reason, - Object result) { - - ensureInitialized(outerDc).join(); - - this.checkForVersionChange(outerDc).join(); - - // Containers are typically leaf nodes on the stack but the dev is free to push - // other dialogs - // on top of the stack which will result in the container receiving an - // unexpected call to - // dialogResume() when the pushed on dialog ends. - // To avoid the container prematurely ending we need to implement this method - // and simply - // ask our inner dialog stack to re-prompt. - repromptDialog(outerDc.getContext(), outerDc.getActiveDialog()).join(); - return CompletableFuture.completedFuture(END_OF_TURN); - } + /** + * Called when the dialog should re-prompt the user for input. + * + * @param turnContext The context Object for this turn. + * @param instance State information for this dialog. + * + * @return A {@link CompletableFuture} representing the hronous operation. + */ + @Override + public CompletableFuture repromptDialog(TurnContext turnContext, DialogInstance instance) { + // Delegate to inner dialog. + DialogContext innerDc = this.createInnerDc(turnContext, instance); + return innerDc.repromptDialog().thenCompose(result -> onRepromptDialog(turnContext, instance)); + } - /** - * Called when the dialog should re-prompt the user for input. - * - * @param turnContext The context Object for this turn. - * @param instance State information for this dialog. - * - * @return A {@link CompletableFuture} representing the hronous operation. - */ - @Override - public CompletableFuture repromptDialog(TurnContext turnContext, DialogInstance instance) { - // Delegate to inner dialog. + /** + * Called when the dialog is ending. + * + * @param turnContext The context Object for this turn. + * @param instance State information associated with the instance of this + * component dialog on its parent's dialog stack. + * @param reason Reason why the dialog ended. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * When this method is called from the parent dialog's context, the + * component dialog cancels all of the dialogs on its inner dialog stack + * before ending. + */ + @Override + public CompletableFuture endDialog(TurnContext turnContext, DialogInstance instance, DialogReason reason) { + // Forward cancel to inner dialogs + if (reason == DialogReason.CANCEL_CALLED) { DialogContext innerDc = this.createInnerDc(turnContext, instance); - innerDc.repromptDialog().join(); - - // Notify component - return onRepromptDialog(turnContext, instance); - } - - /** - * Called when the dialog is ending. - * - * @param turnContext The context Object for this turn. - * @param instance State information associated with the instance of this - * component dialog on its parent's dialog stack. - * @param reason Reason why the dialog ended. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * When this method is called from the parent dialog's context, the - * component dialog cancels all of the dialogs on its inner dialog stack - * before ending. - */ - @Override - public CompletableFuture endDialog(TurnContext turnContext, DialogInstance instance, - DialogReason reason) { - // Forward cancel to inner dialogs - if (reason == DialogReason.CANCEL_CALLED) { - DialogContext innerDc = this.createInnerDc(turnContext, instance); - innerDc.cancelAllDialogs().join(); - } - + return innerDc.cancelAllDialogs().thenCompose(result -> onEndDialog(turnContext, instance, reason)); + } else { return onEndDialog(turnContext, instance, reason); } + } - /** - * Adds a new {@link Dialog} to the component dialog and returns the updated - * component. - * - * @param dialog The dialog to add. - * - * @return The {@link ComponentDialog} after the operation is complete. - * - * The added dialog's {@link Dialog#telemetryClient} is set to the - * {@link DialogContainer#telemetryClient} of the component dialog. - */ - public ComponentDialog addDialog(Dialog dialog) { - this.getDialogs().add(dialog); - - if (this.getInitialDialogId() == null) { - this.setInitialDialogId(dialog.getId()); - } + /** + * Adds a new {@link Dialog} to the component dialog and returns the updated + * component. + * + * @param dialog The dialog to add. + * + * @return The {@link ComponentDialog} after the operation is complete. + * + * The added dialog's {@link Dialog#telemetryClient} is set to the + * {@link DialogContainer#telemetryClient} of the component dialog. + */ + public ComponentDialog addDialog(Dialog dialog) { + this.getDialogs().add(dialog); - return this; + if (this.getInitialDialogId() == null) { + this.setInitialDialogId(dialog.getId()); } - /** - * Creates an inner {@link DialogContext} . - * - * @param dc The parent {@link DialogContext} . - * - * @return The created Dialog Context. - */ - @Override - public DialogContext createChildContext(DialogContext dc) { - return this.createInnerDc(dc, dc.getActiveDialog()); - } + return this; + } - /** - * Ensures the dialog is initialized. - * - * @param outerDc The outer {@link DialogContext} . - * - * @return A {@link CompletableFuture} representing the hronous operation. - */ - protected CompletableFuture ensureInitialized(DialogContext outerDc) { - if (!this.initialized) { - this.initialized = true; - onInitialize(outerDc).join(); - } + /** + * Creates an inner {@link DialogContext} . + * + * @param dc The parent {@link DialogContext} . + * + * @return The created Dialog Context. + */ + @Override + public DialogContext createChildContext(DialogContext dc) { + return this.createInnerDc(dc, dc.getActiveDialog()); + } + + /** + * Ensures the dialog is initialized. + * + * @param outerDc The outer {@link DialogContext} . + * + * @return A {@link CompletableFuture} representing the hronous operation. + */ + protected CompletableFuture ensureInitialized(DialogContext outerDc) { + if (!this.initialized) { + this.initialized = true; + return onInitialize(outerDc).thenApply(result -> null); + } else { return CompletableFuture.completedFuture(null); } + } - /** - * Initilizes the dialog. - * - * @param dc The {@link DialogContext} to initialize. - * - * @return A {@link CompletableFuture} representing the hronous operation. - */ - protected CompletableFuture onInitialize(DialogContext dc) { - if (this.getInitialDialogId() == null) { - Collection dialogs = getDialogs().getDialogs(); - if (dialogs.size() > 0) { - this.setInitialDialogId(dialogs.stream().findFirst().get().getId()); - } + /** + * Initilizes the dialog. + * + * @param dc The {@link DialogContext} to initialize. + * + * @return A {@link CompletableFuture} representing the hronous operation. + */ + protected CompletableFuture onInitialize(DialogContext dc) { + if (this.getInitialDialogId() == null) { + Collection dialogs = getDialogs().getDialogs(); + if (dialogs.size() > 0) { + this.setInitialDialogId(dialogs.stream().findFirst().get().getId()); } - - return CompletableFuture.completedFuture(null); } - /** - * Called when the dialog is started and pushed onto the parent's dialog stack. - * - * @param innerDc The inner {@link DialogContext} for the current turn of - * conversation. - * @param options Optional, initial information to pass to the dialog. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * If the task is successful, the result indicates whether the dialog is - * still active after the turn has been processed by the dialog. By - * default, this calls the - * {@link Dialog#beginDialog(DialogContext, Object)} method of the - * component dialog's initial dialog, as defined by - * {@link InitialDialogId} . Override this method in a derived class to - * implement interrupt logic. - */ - protected CompletableFuture onBeginDialog(DialogContext innerDc, Object options) { - return innerDc.beginDialog(getInitialDialogId(), options); - } + return CompletableFuture.completedFuture(null); + } - /** - * Called when the dialog is _continued_, where it is the active dialog and the - * user replies with a new activity. - * - * @param innerDc The inner {@link DialogContext} for the current turn of - * conversation. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * 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. By default, this calls the - * currently active inner dialog's - * {@link Dialog#continueDialog(DialogContext)} method. Override this - * method in a derived class to implement interrupt logic. - */ - protected CompletableFuture onContinueDialog(DialogContext innerDc) { - return innerDc.continueDialog(); - } + /** + * Called when the dialog is started and pushed onto the parent's dialog stack. + * + * @param innerDc The inner {@link DialogContext} for the current turn of + * conversation. + * @param options Optional, initial information to pass to the dialog. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * If the task is successful, the result indicates whether the dialog is + * still active after the turn has been processed by the dialog. By + * default, this calls the + * {@link Dialog#beginDialog(DialogContext, Object)} method of the + * component dialog's initial dialog, as defined by + * {@link InitialDialogId} . Override this method in a derived class to + * implement interrupt logic. + */ + protected CompletableFuture onBeginDialog(DialogContext innerDc, Object options) { + return innerDc.beginDialog(getInitialDialogId(), options); + } - /** - * Called when the dialog is ending. - * - * @param context The context Object for this turn. - * @param instance State information associated with the inner dialog stack of - * this component dialog. - * @param reason Reason why the dialog ended. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * Override this method in a derived class to implement any additional - * logic that should happen at the component level, after all inner - * dialogs have been canceled. - */ - protected CompletableFuture onEndDialog(TurnContext context, DialogInstance instance, - DialogReason reason) { - return CompletableFuture.completedFuture(null); - } + /** + * Called when the dialog is _continued_, where it is the active dialog and the + * user replies with a new activity. + * + * @param innerDc The inner {@link DialogContext} for the current turn of + * conversation. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * 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. By default, this calls the + * currently active inner dialog's + * {@link Dialog#continueDialog(DialogContext)} method. Override this + * method in a derived class to implement interrupt logic. + */ + protected CompletableFuture onContinueDialog(DialogContext innerDc) { + return innerDc.continueDialog(); + } - /** - * Called when the dialog should re-prompt the user for input. - * - * @param turnContext The context Object for this turn. - * @param instance State information associated with the inner dialog stack - * of this component dialog. - * - * @return A {@link CompletableFuture} representing the hronous operation. - * - * Override this method in a derived class to implement any additional - * logic that should happen at the component level, after the re-prompt - * operation completes for the inner dialog. - */ - protected CompletableFuture onRepromptDialog(TurnContext turnContext, DialogInstance instance) { - return CompletableFuture.completedFuture(null); - } + /** + * Called when the dialog is ending. + * + * @param context The context Object for this turn. + * @param instance State information associated with the inner dialog stack of + * this component dialog. + * @param reason Reason why the dialog ended. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * Override this method in a derived class to implement any additional + * logic that should happen at the component level, after all inner + * dialogs have been canceled. + */ + protected CompletableFuture onEndDialog(TurnContext context, DialogInstance instance, DialogReason reason) { + return CompletableFuture.completedFuture(null); + } - /** - * Ends the component dialog in its parent's context. - * - * @param outerDc The parent {@link DialogContext} for the current turn of - * conversation. - * @param result Optional, value to return from the dialog component to the - * parent context. - * - * @return A task that represents the work queued to execute. - * - * If the task is successful, the result indicates that the dialog ended - * after the turn was processed by the dialog. In general, the parent - * context is the dialog or bot turn handler that started the dialog. If - * the parent is a dialog, the stack calls the parent's - * {@link Dialog#resumeDialog(DialogContext, DialogReason, Object)} - * method to return a result to the parent dialog. If the parent dialog - * does not implement `ResumeDialog`, then the parent will end, too, and - * the result is passed to the next parent context, if one exists. The - * returned {@link DialogTurnResult} contains the return value in its - * {@link DialogTurnResult#result} property. - */ - protected CompletableFuture endComponent(DialogContext outerDc, Object result) { - return outerDc.endDialog(result); - } + /** + * Called when the dialog should re-prompt the user for input. + * + * @param turnContext The context Object for this turn. + * @param instance State information associated with the inner dialog stack + * of this component dialog. + * + * @return A {@link CompletableFuture} representing the hronous operation. + * + * Override this method in a derived class to implement any additional + * logic that should happen at the component level, after the re-prompt + * operation completes for the inner dialog. + */ + protected CompletableFuture onRepromptDialog(TurnContext turnContext, DialogInstance instance) { + return CompletableFuture.completedFuture(null); + } - private static DialogState buildDialogState(DialogInstance instance) { - DialogState state; + /** + * Ends the component dialog in its parent's context. + * + * @param outerDc The parent {@link DialogContext} for the current turn of + * conversation. + * @param result Optional, value to return from the dialog component to the + * parent context. + * + * @return A task that represents the work queued to execute. + * + * If the task is successful, the result indicates that the dialog ended + * after the turn was processed by the dialog. In general, the parent + * context is the dialog or bot turn handler that started the dialog. If + * the parent is a dialog, the stack calls the parent's + * {@link Dialog#resumeDialog(DialogContext, DialogReason, Object)} + * method to return a result to the parent dialog. If the parent dialog + * does not implement `ResumeDialog`, then the parent will end, too, and + * the result is passed to the next parent context, if one exists. The + * returned {@link DialogTurnResult} contains the return value in its + * {@link DialogTurnResult#result} property. + */ + protected CompletableFuture endComponent(DialogContext outerDc, Object result) { + return outerDc.endDialog(result); + } - if (instance.getState().containsKey(PERSISTEDDIALOGSTATE)) { - state = (DialogState) instance.getState().get(PERSISTEDDIALOGSTATE); - } else { - state = new DialogState(); - instance.getState().put(PERSISTEDDIALOGSTATE, state); - } + private static DialogState buildDialogState(DialogInstance instance) { + DialogState state; - if (state.getDialogStack() == null) { - state.setDialogStack(new ArrayList()); - } + if (instance.getState().containsKey(PERSISTEDDIALOGSTATE)) { + state = (DialogState) instance.getState().get(PERSISTEDDIALOGSTATE); + } else { + state = new DialogState(); + instance.getState().put(PERSISTEDDIALOGSTATE, state); + } - return state; + if (state.getDialogStack() == null) { + state.setDialogStack(new ArrayList()); } - private DialogContext createInnerDc(DialogContext outerDc, DialogInstance instance) { - DialogState state = buildDialogState(instance); + return state; + } - return new DialogContext(this.getDialogs(), outerDc, state); - } + private DialogContext createInnerDc(DialogContext outerDc, DialogInstance instance) { + DialogState state = buildDialogState(instance); - // NOTE: You should only call this if you don't have a dc to work with (such as OnResume()) - private DialogContext createInnerDc(TurnContext turnContext, DialogInstance instance) { - DialogState state = buildDialogState(instance); + return new DialogContext(this.getDialogs(), outerDc, state); + } - return new DialogContext(this.getDialogs(), turnContext, state); - } - /** - * Gets the id assigned to the initial dialog. - * @return the InitialDialogId value as a String. - */ - public String getInitialDialogId() { - return this.initialDialogId; - } + // NOTE: You should only call this if you don't have a dc to work with (such as + // OnResume()) + private DialogContext createInnerDc(TurnContext turnContext, DialogInstance instance) { + DialogState state = buildDialogState(instance); - /** - * Sets the id assigned to the initial dialog. - * @param withInitialDialogId The InitialDialogId value. - */ - public void setInitialDialogId(String withInitialDialogId) { - this.initialDialogId = withInitialDialogId; - } + return new DialogContext(this.getDialogs(), turnContext, state); + } + + /** + * Gets the id assigned to the initial dialog. + * + * @return the InitialDialogId value as a String. + */ + public String getInitialDialogId() { + return this.initialDialogId; + } + + /** + * Sets the id assigned to the initial dialog. + * + * @param withInitialDialogId The InitialDialogId value. + */ + public void setInitialDialogId(String withInitialDialogId) { + this.initialDialogId = withInitialDialogId; } +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/Dialog.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/Dialog.java index 59974a94a..f20a3ae2e 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/Dialog.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/Dialog.java @@ -5,10 +5,21 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.builder.BotAdapter; import com.microsoft.bot.builder.BotTelemetryClient; import com.microsoft.bot.builder.NullBotTelemetryClient; import com.microsoft.bot.builder.StatePropertyAccessor; import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.skills.SkillConversationReference; +import com.microsoft.bot.builder.skills.SkillHandler; +import com.microsoft.bot.connector.authentication.AuthenticationConstants; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.EndOfConversationCodes; + import java.util.concurrent.CompletableFuture; import org.apache.commons.lang3.StringUtils; @@ -295,20 +306,100 @@ public static CompletableFuture run( dialogSet.setTelemetryClient(dialog.getTelemetryClient()); return dialogSet.createContext(turnContext) - .thenCompose(dialogContext -> continueOrStart(dialogContext, dialog)) + .thenCompose(dialogContext -> continueOrStart(dialogContext, dialog, turnContext)) .thenApply(result -> null); } - private static CompletableFuture continueOrStart( - DialogContext dialogContext, Dialog dialog + private static CompletableFuture continueOrStart( + DialogContext dialogContext, Dialog dialog, TurnContext turnContext ) { + if (DialogCommon.isFromParentToSkill(turnContext)) { + // Handle remote cancellation request from parent. + if (turnContext.getActivity().getType().equals(ActivityTypes.END_OF_CONVERSATION)) { + if (dialogContext.getStack().size() == 0) { + // No dialogs to cancel, just return. + return CompletableFuture.completedFuture(null); + } + + DialogContext 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, null, null).thenApply(result -> null); + } + + // Handle a reprompt event sent from the parent. + if (turnContext.getActivity().getType().equals(ActivityTypes.EVENT) + && turnContext.getActivity().getName().equals(DialogEvents.REPROMPT_DIALOG)) { + if (dialogContext.getStack().size() == 0) { + // No dialogs to reprompt, just return. + return CompletableFuture.completedFuture(null); + } + + return dialogContext.repromptDialog(); + } + } return dialogContext.continueDialog() .thenCompose(result -> { if (result.getStatus() == DialogTurnStatus.EMPTY) { - return dialogContext.beginDialog(dialog.getId(), null); + return dialogContext.beginDialog(dialog.getId(), null).thenCompose(finalResult -> { + return processEOC(finalResult, turnContext); + }); } - - return CompletableFuture.completedFuture(result); + return processEOC(result, turnContext); }); } + + private static CompletableFuture processEOC(DialogTurnResult result, TurnContext turnContext) { + if (result.getStatus() == DialogTurnStatus.COMPLETE + || result.getStatus() == DialogTurnStatus.CANCELLED + && sendEoCToParent(turnContext)) { + EndOfConversationCodes code = result.getStatus() == DialogTurnStatus.COMPLETE + ? EndOfConversationCodes.COMPLETED_SUCCESSFULLY + : EndOfConversationCodes.USER_CANCELLED; + Activity activity = new Activity(ActivityTypes.END_OF_CONVERSATION); + activity.setValue(result.getResult()); + activity.setLocalTimeZone(turnContext.getActivity().getLocale()); + activity.setCode(code); + return turnContext.sendActivity(activity).thenApply(finalResult -> null); + } + return CompletableFuture.completedFuture(null); + } + + /** + * Helper to determine if we should send an EoC to the parent or not. + * @param turnContext + * @return + */ + private static boolean sendEoCToParent(TurnContext turnContext) { + + ClaimsIdentity claimsIdentity = turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + + if (claimsIdentity != null && SkillValidation.isSkillClaim(claimsIdentity.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. + SkillConversationReference skillConversationReference = + turnContext.getTurnState().get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY); + if (skillConversationReference != null) { + // 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.getOAuthScope() + != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + && skillConversationReference.getOAuthScope() + != GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; + } + return true; + } + return false; + } + + // Recursively walk up the DC stack to find the active DC. + private static DialogContext getActiveDialogContext(DialogContext dialogContext) { + DialogContext child = dialogContext.getChild(); + if (child == null) { + return dialogContext; + } + + return getActiveDialogContext(child); + } } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogCommon.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogCommon.java new file mode 100644 index 000000000..0ddcc8382 --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogCommon.java @@ -0,0 +1,37 @@ +package com.microsoft.bot.dialogs; + +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.skills.SkillHandler; +import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.SkillValidation; + +/** + * A class to contain code that is duplicated across multiple Dialog related + * classes and can be shared through this common class. + */ +final class DialogCommon { + + private DialogCommon() { + + } + + /** + * Determine if a turnContext is from a Parent to a Skill. + * @param turnContext the turnContext. + * @return true if the turnContext is from a Parent to a Skill, false otherwise. + */ + static boolean isFromParentToSkill(TurnContext turnContext) { + if (turnContext.getTurnState().get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY) != null) { + return false; + } + + Object identity = turnContext.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + if (identity instanceof ClaimsIdentity) { + ClaimsIdentity claimsIdentity = (ClaimsIdentity) identity; + return SkillValidation.isSkillClaim(claimsIdentity.claims()); + } else { + return false; + } + } +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogManager.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogManager.java index 38965e924..519465aeb 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogManager.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/DialogManager.java @@ -17,14 +17,18 @@ import com.microsoft.bot.builder.TurnContext; import com.microsoft.bot.builder.TurnContextStateCollection; import com.microsoft.bot.builder.UserState; +import com.microsoft.bot.builder.skills.SkillConversationReference; +import com.microsoft.bot.builder.skills.SkillHandler; import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.AuthenticationConstants; import com.microsoft.bot.connector.authentication.ClaimsIdentity; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; import com.microsoft.bot.connector.authentication.SkillValidation; import com.microsoft.bot.dialogs.memory.DialogStateManager; import com.microsoft.bot.dialogs.memory.DialogStateManagerConfiguration; import com.microsoft.bot.schema.Activity; - -import org.apache.commons.lang3.NotImplementedException; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.EndOfConversationCodes; /** * Class which runs the dialog system. @@ -294,7 +298,7 @@ public CompletableFuture onTurn(TurnContext context) { ClaimsIdentity claimIdentity = context.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); if (claimIdentity != null && SkillValidation.isSkillClaim(claimIdentity.claims())) { // The bot is running as a skill. - turnResult = handleSkillOnTurn().join(); + turnResult = handleSkillOnTurn(dc).join(); } else { // The bot is running as root bot. turnResult = handleBotOnTurn(dc).join(); @@ -351,12 +355,86 @@ private static DialogContext getActiveDialogContext(DialogContext dialogContext) } } - @SuppressWarnings({"TodoComment"}) - //TODO: Add Skills support here - private CompletableFuture handleSkillOnTurn() { - return Async.completeExceptionally(new NotImplementedException( - "Skills are not implemented in this release" - )); + private CompletableFuture handleSkillOnTurn(DialogContext dc) { + // the bot instanceof running as a skill. + TurnContext turnContext = dc.getContext(); + + // Process remote cancellation + if (turnContext.getActivity().getType().equals(ActivityTypes.END_OF_CONVERSATION) + && dc.getActiveDialog() != null + && DialogCommon.isFromParentToSkill(turnContext)) { + // Handle remote cancellation request from parent. + DialogContext activeDialogContext = getActiveDialogContext(dc); + + // Send cancellation message to the top dialog in the stack to ensure all the + // parents are canceled in the right order. + return activeDialogContext.cancelAllDialogs(); + } + + // Handle reprompt + // Process a reprompt event sent from the parent. + if (turnContext.getActivity().getType().equals(ActivityTypes.EVENT) + && turnContext.getActivity().getName().equals(DialogEvents.REPROMPT_DIALOG)) { + if (dc.getActiveDialog() == null) { + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.EMPTY)); + } + + dc.repromptDialog(); + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.WAITING)); + } + + // Continue execution + // - This will apply any queued up interruptions and execute the current/next step(s). + DialogTurnResult turnResult = dc.continueDialog().join(); + if (turnResult.getStatus().equals(DialogTurnStatus.EMPTY)) { + // restart root dialog + turnResult = dc.beginDialog(rootDialogId).join(); + } + + sendStateSnapshotTrace(dc, "Skill State"); + + if (shouldSendEndOfConversationToParent(turnContext, turnResult)) { + // Send End of conversation at the end. + EndOfConversationCodes code = turnResult.getStatus().equals(DialogTurnStatus.COMPLETE) + ? EndOfConversationCodes.COMPLETED_SUCCESSFULLY + : EndOfConversationCodes.USER_CANCELLED; + Activity activity = new Activity(ActivityTypes.END_OF_CONVERSATION); + activity.setValue(turnResult.getResult()); + activity.setLocale(turnContext.getActivity().getLocale()); + activity.setCode(code); + turnContext.sendActivity(activity).join(); + } + + return CompletableFuture.completedFuture(turnResult); + } + + /** + * Helper to determine if we should send an EndOfConversation to the parent + * or not. + */ + private static boolean shouldSendEndOfConversationToParent(TurnContext context, DialogTurnResult turnResult) { + if (!(turnResult.getStatus().equals(DialogTurnStatus.COMPLETE) + || turnResult.getStatus().equals(DialogTurnStatus.CANCELLED))) { + // The dialog instanceof still going, don't return EoC. + return false; + } + ClaimsIdentity claimsIdentity = context.getTurnState().get(BotAdapter.BOT_IDENTITY_KEY); + if (claimsIdentity != null && SkillValidation.isSkillClaim(claimsIdentity.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. + SkillConversationReference skillConversationReference = + context.getTurnState().get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY); + if (skillConversationReference != null) { + // If the skillConversationReference.OAuthScope instanceof for one of the supported channels, + // we are at the root and we should not send an EoC. + return skillConversationReference.getOAuthScope() + != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + && skillConversationReference.getOAuthScope() + != GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; + } + return true; + } + return false; } /** @@ -395,7 +473,7 @@ private CompletableFuture handleBotOnTurn(DialogContext dc) { // step(s). turnResult = dc.continueDialog().join(); - if (turnResult.getStatus() == DialogTurnStatus.EMPTY) { + if (turnResult.getStatus().equals(DialogTurnStatus.EMPTY)) { // restart root dialog turnResult = dc.beginDialog(rootDialogId).join(); } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialog.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialog.java new file mode 100644 index 000000000..3295c03f8 --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialog.java @@ -0,0 +1,513 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.dialogs; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.UserTokenProvider; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryOptions; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.Attachment; +import com.microsoft.bot.schema.ConversationReference; +import com.microsoft.bot.schema.DeliveryModes; +import com.microsoft.bot.schema.ExpectedReplies; +import com.microsoft.bot.schema.OAuthCard; +import com.microsoft.bot.schema.SignInConstants; +import com.microsoft.bot.schema.TokenExchangeInvokeRequest; +import com.microsoft.bot.schema.TokenExchangeRequest; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A specialized {@link Dialog} that can wrap remote calls to a skill. + * + * The options parameter in {@link BeginDialog} must be a + * {@link BeginSkillDialogOptions} instancewith the initial parameters for the + * dialog. + */ +public class SkillDialog extends Dialog { + + private SkillDialogOptions dialogOptions; + + private final String deliverModeStateKey = "deliverymode"; + private final String skillConversationIdStateKey = "Microsoft.Bot.Builder.Dialogs.SkillDialog.SkillConversationId"; + + /** + * Initializes a new instance of the {@link SkillDialog} class to wrap remote + * calls to a skill. + * + * @param dialogOptions The options to execute the skill dialog. + * @param dialogId The id of the dialog. + */ + public SkillDialog(SkillDialogOptions dialogOptions, String dialogId) { + super(dialogId); + if (dialogOptions == null) { + throw new IllegalArgumentException("dialogOptions cannot be null."); + } + + this.dialogOptions = dialogOptions; + } + + /** + * Called when the skill dialog is started and pushed onto the dialog stack. + * + * @param dc The {@link DialogContext} for the current turn of + * conversation. + * @param options Optional, initial information to pass to the dialog. + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + * + * If the task is successful, the result indicates whether the dialog is + * still active after the turn has been processed by the dialog. + */ + @Override + public CompletableFuture beginDialog(DialogContext dc, Object options) { + BeginSkillDialogOptions dialogArgs = validateBeginDialogArgs(options); + + // Create deep clone of the original activity to avoid altering it before + // forwarding it. + Activity skillActivity = Activity.clone(dialogArgs.getActivity()); + + // Apply conversation reference and common properties from incoming activity + // before sending. + ConversationReference conversationReference = dc.getContext().getActivity().getConversationReference(); + skillActivity.applyConversationReference(conversationReference, true); + + // Store delivery mode and connection name in dialog state for later use. + dc.getActiveDialog().getState().put(deliverModeStateKey, dialogArgs.getActivity().getDeliveryMode()); + + // Create the conversationId and store it in the dialog context state so we can + // use it later + return createSkillConversationId(dc.getContext(), dc.getContext().getActivity()) + .thenCompose(skillConversationId -> { + dc.getActiveDialog().getState().put(skillConversationIdStateKey, skillConversationId); + + // Send the activity to the skill. + return sendToSkill(dc.getContext(), skillActivity, skillConversationId).thenCompose(eocActivity -> { + if (eocActivity != null) { + return dc.endDialog(eocActivity.getValue()); + } + return CompletableFuture.completedFuture(END_OF_TURN); + }); + }); + } + + /** + * Called when the skill dialog is _continued_, where it is the active dialog + * and the user replies with a new activity. + * + * @param dc The {@link DialogContext} for the current turn of conversation. + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + * + * 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. + */ + @Override + public CompletableFuture continueDialog(DialogContext dc) { + if (!onValidateActivity(dc.getContext().getActivity())) { + return CompletableFuture.completedFuture(END_OF_TURN); + } + + // Handle EndOfConversation from the skill (this will be sent to the this dialog + // by the SkillHandler + // if received from the Skill) + if (dc.getContext().getActivity().getType().equals(ActivityTypes.END_OF_CONVERSATION)) { + return dc.endDialog(dc.getContext().getActivity().getValue()); + } + + // Create deep clone of the original activity to avoid altering it before + // forwarding it. + Activity skillActivity = Activity.clone(dc.getContext().getActivity()); + if (dc.getActiveDialog().getState().get(deliverModeStateKey) != null) { + skillActivity.setDeliveryMode((String) dc.getActiveDialog().getState().get(deliverModeStateKey)); + } + + String skillConversationId = (String) dc.getActiveDialog().getState().get(skillConversationIdStateKey); + + // Just forward to the remote skill + return sendToSkill(dc.getContext(), skillActivity, skillConversationId).thenCompose(eocActivity -> { + if (eocActivity != null) { + return dc.endDialog(eocActivity.getValue()); + } + + return CompletableFuture.completedFuture(END_OF_TURN); + }); + } + + /** + * Called when the skill dialog should re-prompt the user for input. + * + * @param turnContext The context Object for this turn. + * @param instance State information for this dialog. + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + */ + @Override + public CompletableFuture repromptDialog(TurnContext turnContext, DialogInstance instance) { + // Create and send an envent to the skill so it can resume the dialog. + Activity repromptEvent = Activity.createEventActivity(); + repromptEvent.setName(DialogEvents.REPROMPT_DIALOG); + + // Apply conversation reference and common properties from incoming activity + // before sending. + repromptEvent.applyConversationReference(turnContext.getActivity().getConversationReference(), true); + + String skillConversationId = (String) instance.getState().get(skillConversationIdStateKey); + + // connection Name instanceof not applicable for a RePrompt, as we don't expect + // as OAuthCard in response. + return sendToSkill(turnContext, (Activity) repromptEvent, skillConversationId).thenApply(result -> null); + } + + /** + * Called when a child skill dialog completed its turn, returning control to + * this dialog. + * + * @param dc The dialog context for the current turn of the conversation. + * @param reason Reason 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. + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + */ + @Override + public CompletableFuture resumeDialog(DialogContext dc, DialogReason reason, Object result) { + return repromptDialog(dc.getContext(), dc.getActiveDialog()).thenCompose(x -> { + return CompletableFuture.completedFuture(END_OF_TURN); + }); + } + + /** + * Called when the skill dialog is ending. + * + * @param turnContext The context Object for this turn. + * @param instance State information associated with the instance of this + * dialog on the dialog stack. + * @param reason Reason why the dialog ended. + * + * @return A {@link CompletableFuture} representing the asynchronous operation. + */ + @Override + public CompletableFuture endDialog(TurnContext turnContext, DialogInstance instance, DialogReason reason) { + // Send of of conversation to the skill if the dialog has been cancelled. + return onEndDialog(turnContext, instance, reason) + .thenCompose(result -> super.endDialog(turnContext, instance, reason)); + } + + private CompletableFuture onEndDialog(TurnContext turnContext, DialogInstance instance, DialogReason reason) { + if (reason == DialogReason.CANCEL_CALLED || reason == DialogReason.REPLACE_CALLED) { + Activity activity = Activity.createEndOfConversationActivity(); + + // Apply conversation reference and common properties from incoming activity + // before sending. + activity.applyConversationReference(turnContext.getActivity().getConversationReference(), true); + activity.setChannelData(turnContext.getActivity().getChannelData()); + for (Map.Entry entry : turnContext.getActivity().getProperties().entrySet()) { + activity.setProperties(entry.getKey(), entry.getValue()); + } + + String skillConversationId = (String) instance.getState().get(skillConversationIdStateKey); + + // connection Name instanceof not applicable for an EndDialog, as we don't + // expect as OAuthCard in response. + return sendToSkill(turnContext, activity, skillConversationId).thenApply(result -> null); + } else { + return CompletableFuture.completedFuture(null); + } + + } + + /** + * Validates the activity sent during {@link ContinueDialog} . + * + * @param activity The {@link Activity} for the current turn of conversation. + * + * Override this method to implement a custom validator for the + * activity being sent during the {@link 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. + * + * @return true if the activity is valid, false if not. + */ + protected boolean onValidateActivity(Activity activity) { + return true; + } + + /** + * Validates the required properties are set in the options argument passed to + * the BeginDialog call. + */ + private static BeginSkillDialogOptions validateBeginDialogArgs(Object options) { + if (options == null) { + throw new IllegalArgumentException("options cannot be null."); + } + + if (!(options instanceof BeginSkillDialogOptions)) { + throw new IllegalArgumentException("Unable to cast options to beginSkillDialogOptions}"); + } + + BeginSkillDialogOptions dialogArgs = (BeginSkillDialogOptions) options; + + if (dialogArgs.getActivity() == null) { + throw new IllegalArgumentException("dialogArgs.getActivity is null in options"); + } + + return dialogArgs; + } + + private CompletableFuture sendToSkill(TurnContext context, Activity activity, + String skillConversationId) { + if (activity.getType().equals(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.setDeliveryMode(DeliveryModes.EXPECT_REPLIES.toString()); + } + + // Always save state before forwarding + // (the dialog stack won't get updated with the skillDialog and things won't + // work if you don't) + getDialogOptions().getConversationState().saveChanges(context, true); + + BotFrameworkSkill skillInfo = getDialogOptions().getSkill(); + return getDialogOptions().getSkillClient() + .postActivity(getDialogOptions().getBotId(), skillInfo.getAppId(), skillInfo.getSkillEndpoint(), + getDialogOptions().getSkillHostEndpoint(), skillConversationId, activity, Object.class) + .thenCompose(response -> { + // Inspect the skill response status + if (!response.getIsSuccessStatusCode()) { + return Async.completeExceptionally(new SkillInvokeException(String.format( + "Error invoking the skill id: %s at %s (status is %s). %s", skillInfo.getId(), + skillInfo.getSkillEndpoint(), response.getStatus(), response.getBody()))); + } + + ExpectedReplies replies = null; + if (response.getBody() instanceof ExpectedReplies) { + replies = (ExpectedReplies) response.getBody(); + } + + Activity eocActivity = null; + if (activity.getDeliveryMode() != null + && activity.getDeliveryMode().equals(DeliveryModes.EXPECT_REPLIES.toString()) + && replies.getActivities() != null && replies.getActivities().size() > 0) { + // Track sent invoke responses, so more than one instanceof not sent. + boolean sentInvokeResponse = false; + + // Process replies in the response.getBody(). + for (Activity activityFromSkill : replies.getActivities()) { + if (activityFromSkill.getType().equals(ActivityTypes.END_OF_CONVERSATION)) { + // Capture the EndOfConversation activity if it was sent from skill + eocActivity = activityFromSkill; + + // The conversation has ended, so cleanup the conversation id. + getDialogOptions().getConversationIdFactory() + .deleteConversationReference(skillConversationId).join(); + } else if (!sentInvokeResponse && interceptOAuthCards(context, activityFromSkill, + getDialogOptions().getConnectionName()).join()) { + // do nothing. Token exchange succeeded, so no OAuthCard needs to be shown to + // the user + sentInvokeResponse = true; + } else { + if (activityFromSkill.getType().equals(ActivityTypes.INVOKE_RESPONSE)) { + // An invoke respones has already been sent. This instanceof a bug in the skill. + // Multiple invoke responses are not possible. + if (sentInvokeResponse) { + continue; + } + + sentInvokeResponse = true; + + // Not sure this is needed in Java, looks like a workaround for some .NET issues + // Ensure the value in the invoke response instanceof of type InvokeResponse + // (it gets deserialized as JObject by default). + + // if (activityFromSkill.getValue() instanceof JObject jObject) { + // activityFromSkill.setValue(jObject.ToObject()); + // } + } + + // Send the response back to the channel. + context.sendActivity(activityFromSkill); + } + } + } + + return CompletableFuture.completedFuture(eocActivity); + + }); + } + + /** + * Tells is if we should intercept the OAuthCard message. + * + * 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 CompletableFuture interceptOAuthCards(TurnContext turnContext, Activity activity, + String connectionName) { + + UserTokenProvider tokenExchangeProvider; + + if (StringUtils.isEmpty(connectionName) || !(turnContext.getAdapter() instanceof UserTokenProvider)) { + // The adapter may choose not to support token exchange, + // in which case we fallback to showing an oauth card to the user. + return CompletableFuture.completedFuture(false); + } else { + tokenExchangeProvider = (UserTokenProvider) turnContext.getAdapter(); + } + + Attachment oauthCardAttachment = null; + + if (activity.getAttachments() != null) { + Optional optionalAttachment = activity.getAttachments().stream() + .filter(a -> a.getContentType() != null && a.getContentType().equals(OAuthCard.CONTENTTYPE)) + .findFirst(); + if (optionalAttachment.isPresent()) { + oauthCardAttachment = optionalAttachment.get(); + } + } + + if (oauthCardAttachment != null) { + OAuthCard oauthCard = (OAuthCard) oauthCardAttachment.getContent(); + if (oauthCard != null && oauthCard.getTokenExchangeResource() != null + && !StringUtils.isEmpty(oauthCard.getTokenExchangeResource().getUri())) { + try { + return tokenExchangeProvider + .exchangeToken(turnContext, connectionName, turnContext.getActivity().getFrom().getId(), + new TokenExchangeRequest(oauthCard.getTokenExchangeResource().getUri(), null)) + .thenCompose(result -> { + if (result != null && !StringUtils.isEmpty(result.getToken())) { + // If token above instanceof null, then SSO has failed and hence we return + // false. + // If not, send an invoke to the skill with the token. + return sendTokenExchangeInvokeToSkill(activity, + oauthCard.getTokenExchangeResource().getId(), oauthCard.getConnectionName(), + result.getToken()); + } else { + return CompletableFuture.completedFuture(false); + } + + }); + } catch (Exception ex) { + // Failures in token exchange are not fatal. They simply mean that the user + // needs + // to be shown the OAuth card. + return CompletableFuture.completedFuture(false); + } + } + } + return CompletableFuture.completedFuture(false); + } + + // private CompletableFuture interceptOAuthCards(TurnContext turnContext, Activity activity, + // String connectionName) { + + // UserTokenProvider tokenExchangeProvider; + + // if (StringUtils.isEmpty(connectionName) || !(turnContext.getAdapter() instanceof UserTokenProvider)) { + // // The adapter may choose not to support token exchange, + // // in which case we fallback to showing an oauth card to the user. + // return CompletableFuture.completedFuture(false); + // } else { + // tokenExchangeProvider = (UserTokenProvider) turnContext.getAdapter(); + // } + + // Attachment oauthCardAttachment = null; + + // if (activity.getAttachments() != null) { + // Optional optionalAttachment = activity.getAttachments().stream() + // .filter(a -> a.getContentType() != null && a.getContentType().equals(OAuthCard.CONTENTTYPE)) + // .findFirst(); + // if (optionalAttachment.isPresent()) { + // oauthCardAttachment = optionalAttachment.get(); + // } + // } + + // if (oauthCardAttachment != null) { + // OAuthCard oauthCard = (OAuthCard) oauthCardAttachment.getContent(); + // if (oauthCard != null && oauthCard.getTokenExchangeResource() != null + // && !StringUtils.isEmpty(oauthCard.getTokenExchangeResource().getUri())) { + // try { + // TokenResponse result = tokenExchangeProvider + // .exchangeToken(turnContext, connectionName, turnContext.getActivity().getFrom().getId(), + // new TokenExchangeRequest(oauthCard.getTokenExchangeResource().getUri(), null)) + // .join(); + + // if (result != null && !StringUtils.isEmpty(result.getToken())) { + // // If token above instanceof null, then SSO has failed and hence we return + // // false. + // // If not, send an invoke to the skill with the token. + // return sendTokenExchangeInvokeToSkill(activity, oauthCard.getTokenExchangeResource().getId(), + // oauthCard.getConnectionName(), result.getToken()); + // } + // } catch (Exception ex) { + // // Failures in token exchange are not fatal. They simply mean that the user + // // needs + // // to be shown the OAuth card. + // return CompletableFuture.completedFuture(false); + // } + // } + // } + + // return CompletableFuture.completedFuture(false); + // } + + + private CompletableFuture sendTokenExchangeInvokeToSkill(Activity incomingActivity, String id, + String connectionName, String token) { + Activity activity = incomingActivity.createReply(); + activity.setType(ActivityTypes.INVOKE); + activity.setName(SignInConstants.TOKEN_EXCHANGE_OPERATION_NAME); + TokenExchangeInvokeRequest tokenRequest = new TokenExchangeInvokeRequest(); + tokenRequest.setId(id); + tokenRequest.setToken(token); + tokenRequest.setConnectionName(connectionName); + activity.setValue(tokenRequest); + + // route the activity to the skill + BotFrameworkSkill skillInfo = getDialogOptions().getSkill(); + return getDialogOptions().getSkillClient() + .postActivity(getDialogOptions().getBotId(), skillInfo.getAppId(), skillInfo.getSkillEndpoint(), + getDialogOptions().getSkillHostEndpoint(), incomingActivity.getConversation().getId(), activity, + Object.class) + .thenApply(response -> response.getIsSuccessStatusCode()); + } + + private CompletableFuture createSkillConversationId(TurnContext context, Activity activity) { + // Create a conversationId to interact with the skill and send the activity + SkillConversationIdFactoryOptions conversationIdFactoryOptions = new SkillConversationIdFactoryOptions(); + conversationIdFactoryOptions.setFromBotOAuthScope(context.getTurnState().get(BotAdapter.OAUTH_SCOPE_KEY)); + conversationIdFactoryOptions.setFromBotId(getDialogOptions().getBotId()); + conversationIdFactoryOptions.setActivity(activity); + conversationIdFactoryOptions.setBotFrameworkSkill(getDialogOptions().getSkill()); + + return getDialogOptions().getConversationIdFactory().createSkillConversationId(conversationIdFactoryOptions); + } + + /** + * Gets the options used to execute the skill dialog. + * + * @return the DialogOptions value as a SkillDialogOptions. + */ + protected SkillDialogOptions getDialogOptions() { + return this.dialogOptions; + } + +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialogOptions.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialogOptions.java new file mode 100644 index 000000000..53eb9dac1 --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillDialogOptions.java @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.dialogs; + +import java.net.URI; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.skills.BotFrameworkClient; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; + +/** + * Defines the options that will be used to execute a {@link SkillDialog} . + */ +public class SkillDialogOptions { + + @JsonProperty(value = "botId") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String botId; + + private BotFrameworkClient skillClient; + + @JsonProperty(value = "skillHostEndpoint") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private URI skillHostEndpoint; + + @JsonProperty(value = "skill") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private BotFrameworkSkill skill; + + private SkillConversationIdFactoryBase conversationIdFactory; + + private ConversationState conversationState; + + @JsonProperty(value = "connectionName") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String connectionName; + + /** + * Gets the Microsoft app ID of the bot calling the skill. + * @return the BotId value as a String. + */ + public String getBotId() { + return this.botId; + } + + /** + * Sets the Microsoft app ID of the bot calling the skill. + * @param withBotId The BotId value. + */ + public void setBotId(String withBotId) { + this.botId = withBotId; + } + /** + * Gets the {@link BotFrameworkClient} used to call the remote + * skill. + * @return the SkillClient value as a BotFrameworkClient. + */ + public BotFrameworkClient getSkillClient() { + return this.skillClient; + } + + /** + * Sets the {@link BotFrameworkClient} used to call the remote + * skill. + * @param withSkillClient The SkillClient value. + */ + public void setSkillClient(BotFrameworkClient withSkillClient) { + this.skillClient = withSkillClient; + } + /** + * Gets the callback Url for the skill host. + * @return the SkillHostEndpoint value as a Uri. + */ + public URI getSkillHostEndpoint() { + return this.skillHostEndpoint; + } + + /** + * Sets the callback Url for the skill host. + * @param withSkillHostEndpoint The SkillHostEndpoint value. + */ + public void setSkillHostEndpoint(URI withSkillHostEndpoint) { + this.skillHostEndpoint = withSkillHostEndpoint; + } + /** + * Gets the {@link BotFrameworkSkill} that the dialog will call. + * @return the Skill value as a BotFrameworkSkill. + */ + public BotFrameworkSkill getSkill() { + return this.skill; + } + + /** + * Sets the {@link BotFrameworkSkill} that the dialog will call. + * @param withSkill The Skill value. + */ + public void setSkill(BotFrameworkSkill withSkill) { + this.skill = withSkill; + } + /** + * Gets an instance of a {@link SkillConversationIdFactoryBase} + * used to generate conversation IDs for interacting with the skill. + * @return the ConversationIdFactory value as a SkillConversationIdFactoryBase. + */ + public SkillConversationIdFactoryBase getConversationIdFactory() { + return this.conversationIdFactory; + } + + /** + * Sets an instance of a {@link SkillConversationIdFactoryBase} + * used to generate conversation IDs for interacting with the skill. + * @param withConversationIdFactory The ConversationIdFactory value. + */ + public void setConversationIdFactory(SkillConversationIdFactoryBase withConversationIdFactory) { + this.conversationIdFactory = withConversationIdFactory; + } + /** + * Gets the {@link ConversationState} to be used by the dialog. + * @return the ConversationState value as a getConversationState(). + */ + public ConversationState getConversationState() { + return this.conversationState; + } + + /** + * Sets the {@link ConversationState} to be used by the dialog. + * @param withConversationState The ConversationState value. + */ + public void setConversationState(ConversationState withConversationState) { + this.conversationState = withConversationState; + } + /** + * Gets the OAuth Connection Name, that would be used to perform + * Single SignOn with a skill. + * @return the ConnectionName value as a String. + */ + public String getConnectionName() { + return this.connectionName; + } + + /** + * Sets the OAuth Connection Name, that would be used to perform + * Single SignOn with a skill. + * @param withConnectionName The ConnectionName value. + */ + public void setConnectionName(String withConnectionName) { + this.connectionName = withConnectionName; + } +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillInvokeException.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillInvokeException.java new file mode 100644 index 000000000..afda96063 --- /dev/null +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/SkillInvokeException.java @@ -0,0 +1,41 @@ +package com.microsoft.bot.dialogs; + +/** + * Exception used to report issues during the invoke method of the {@link SkillsDialog} class. + */ +public class SkillInvokeException extends RuntimeException { + + /** + * Serial Version for class. + */ + private static final long serialVersionUID = 1L; + + /** + * Construct with exception. + * + * @param t The cause. + */ + public SkillInvokeException(Throwable t) { + super(t); + } + + /** + * Construct with message. + * + * @param message The exception message. + */ + public SkillInvokeException(String message) { + super(message); + } + + /** + * Construct with caught exception and message. + * + * @param message The message. + * @param t The caught exception. + */ + public SkillInvokeException(String message, Throwable t) { + super(message, t); + } + +} diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/memory/DialogStateManager.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/memory/DialogStateManager.java index 3fddb79f7..5bf261f20 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/memory/DialogStateManager.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/memory/DialogStateManager.java @@ -47,7 +47,7 @@ public class DialogStateManager implements Map { */ private final String pathTracker = "dialog._tracker.paths"; - private static final char[] SEPARATORS = {',', '['}; + private static final char[] SEPARATORS = {',', '[' }; private final DialogContext dialogContext; private int version; @@ -324,7 +324,7 @@ public T getValue(String pathExpression, T defaultValue, Class clsType) { } ResultPair result = tryGetValue(pathExpression, clsType); - if (result.result()) { + if (result.result()) { return result.value(); } else { return defaultValue; @@ -487,7 +487,7 @@ public CompletableFuture deleteScopesMemory(String name) { return s.getName().toUpperCase() == uCaseName; }).findFirst().get(); if (scope != null) { - scope.delete(dialogContext).join(); + return scope.delete(dialogContext).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } @@ -808,7 +808,6 @@ public final Object remove(Object key) { public final void putAll(Map m) { } - @Override public final Set keySet() { return configuration.getMemoryScopes().stream().map(scope -> scope.getName()).collect(Collectors.toSet()); @@ -817,7 +816,7 @@ public final Set keySet() { @Override public final Collection values() { return configuration.getMemoryScopes().stream().map(scope -> scope.getMemory(dialogContext)) - .collect(Collectors.toSet()); + .collect(Collectors.toSet()); } @Override diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ActivityPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ActivityPrompt.java index 6c30dae1a..f3cb1e230 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ActivityPrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ActivityPrompt.java @@ -60,40 +60,42 @@ public ActivityPrompt(String dialogId, PromptValidator validator) { } /** - * Called when a prompt dialog is pushed onto the dialog stack and is being activated. + * Called when a prompt dialog is pushed onto the dialog stack and is being + * activated. * - * @param dc The dialog context for the current turn of the conversation. - * @param options Optional, additional information to pass to the prompt being started. + * @param dc The dialog context for the current turn of the conversation. + * @param options Optional, additional information to pass to the prompt being + * started. * - * @return A {@link CompletableFuture} representing the asynchronous operation. + * @return A {@link CompletableFuture} representing the asynchronous operation. * - * If the task is successful, the result indicates whether the prompt is still active after the - * turn has been processed by the prompt. + * If the task is successful, the result indicates whether the prompt is + * still active after the turn has been processed by the prompt. */ @Override public CompletableFuture beginDialog(DialogContext dc, Object options) { if (dc == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "dc cannot be null." - )); + return Async.completeExceptionally(new IllegalArgumentException("dc cannot be null.")); } if (!(options instanceof PromptOptions)) { - return Async.completeExceptionally(new IllegalArgumentException( - "Prompt options are required for Prompt dialogs" - )); + return Async.completeExceptionally( + new IllegalArgumentException("Prompt options are required for Prompt dialogs")); } // Ensure prompts have input hint set - // For Java this code isn't necessary as InputHint is an enumeration, so it's can't be not set to something. + // For Java this code isn't necessary as InputHint is an enumeration, so it's + // can't be not set to something. // PromptOptions opt = (PromptOptions) options; - // if (opt.getPrompt() != null && StringUtils.isBlank(opt.getPrompt().getInputHint().toString())) { - // opt.getPrompt().setInputHint(InputHints.EXPECTING_INPUT); + // if (opt.getPrompt() != null && + // StringUtils.isBlank(opt.getPrompt().getInputHint().toString())) { + // opt.getPrompt().setInputHint(InputHints.EXPECTING_INPUT); // } - // if (opt.getRetryPrompt() != null && StringUtils.isBlank(opt.getRetryPrompt().getInputHint().toString())) { - // opt.getRetryPrompt().setInputHint(InputHints.EXPECTING_INPUT); + // if (opt.getRetryPrompt() != null && + // StringUtils.isBlank(opt.getRetryPrompt().getInputHint().toString())) { + // opt.getRetryPrompt().setInputHint(InputHints.EXPECTING_INPUT); // } // Initialize prompt state @@ -105,10 +107,10 @@ public CompletableFuture beginDialog(DialogContext dc, Object state.put(persistedState, persistedStateMap); // Send initial prompt - onPrompt(dc.getContext(), (Map) state.get(persistedState), - (PromptOptions) state.get(persistedOptions), false); + onPrompt(dc.getContext(), (Map) state.get(persistedState), + (PromptOptions) state.get(persistedOptions), false); - return CompletableFuture.completedFuture(END_OF_TURN); + return CompletableFuture.completedFuture(END_OF_TURN); } /** @@ -127,37 +129,39 @@ public CompletableFuture beginDialog(DialogContext dc, Object @Override public CompletableFuture continueDialog(DialogContext dc) { if (dc == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "dc cannot be null." - )); + return Async.completeExceptionally(new IllegalArgumentException("dc cannot be null.")); } // Perform base recognition DialogInstance instance = dc.getActiveDialog(); Map state = (Map) instance.getState().get(persistedState); PromptOptions options = (PromptOptions) instance.getState().get(persistedOptions); - PromptRecognizerResult recognized = onRecognize(dc.getContext(), state, options).join(); - - state.put(Prompt.ATTEMPTCOUNTKEY, (int) state.get(Prompt.ATTEMPTCOUNTKEY) + 1); + return onRecognize(dc.getContext(), state, options).thenCompose(recognized -> { + state.put(Prompt.ATTEMPTCOUNTKEY, (int) state.get(Prompt.ATTEMPTCOUNTKEY) + 1); + return validateContext(dc, state, options, recognized).thenCompose(isValid -> { + // Return recognized value or re-prompt + if (isValid) { + return dc.endDialog(recognized.getValue()); + } + + return onPrompt(dc.getContext(), state, options, true) + .thenCompose(result -> CompletableFuture.completedFuture(END_OF_TURN)); + }); + }); + } + private CompletableFuture validateContext(DialogContext dc, Map state, + PromptOptions options, PromptRecognizerResult recognized) { // Validate the return value boolean isValid = false; if (validator != null) { PromptValidatorContext promptContext = new PromptValidatorContext(dc.getContext(), recognized, state, options); - isValid = validator.promptValidator(promptContext).join(); + return validator.promptValidator(promptContext); } else if (recognized.getSucceeded()) { isValid = true; } - - // Return recognized value or re-prompt - if (isValid) { - return dc.endDialog(recognized.getValue()); - } - - onPrompt(dc.getContext(), state, options, true); - - return CompletableFuture.completedFuture(END_OF_TURN); + return CompletableFuture.completedFuture(isValid); } /** @@ -221,65 +225,63 @@ public CompletableFuture repromptDialog(TurnContext turnContext, DialogIns */ protected CompletableFuture onPrompt(TurnContext turnContext, Map state, PromptOptions options) { - onPrompt(turnContext, state, options, false).join(); - return CompletableFuture.completedFuture(null); + return onPrompt(turnContext, state, options, false).thenApply(result -> null); } /** * When overridden in a derived class, prompts the user for input. * - * @param turnContext Context for the current turn of conversation with the user. - * @param state Contains state for the current instance of the prompt on the - * dialog stack. - * @param options A prompt options Object constructed from the options initially - * provided in the call to {@link DialogContext#prompt(String, PromptOptions)} . - * @param isRetry A {@link Boolean} representing if the prompt is a retry. + * @param turnContext Context for the current turn of conversation with the + * user. + * @param state Contains state for the current instance of the prompt on + * the dialog stack. + * @param options A prompt options Object constructed from the options + * initially provided in the call to + * {@link DialogContext#prompt(String, PromptOptions)} . + * @param isRetry A {@link Boolean} representing if the prompt is a retry. * - * @return A {@link CompletableFuture} representing the result of the asynchronous - * operation. + * @return A {@link CompletableFuture} representing the result of the + * asynchronous operation. */ - protected CompletableFuture onPrompt( - TurnContext turnContext, - Map state, - PromptOptions options, - Boolean isRetry) { + protected CompletableFuture onPrompt(TurnContext turnContext, Map state, + PromptOptions options, Boolean isRetry) { if (turnContext == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "turnContext cannot be null" - )); + return Async.completeExceptionally(new IllegalArgumentException("turnContext cannot be null")); } if (options == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "options cannot be null" - )); + return Async.completeExceptionally(new IllegalArgumentException("options cannot be null")); } if (isRetry && options.getRetryPrompt() != null) { - turnContext.sendActivity(options.getRetryPrompt()).join(); + return turnContext.sendActivity(options.getRetryPrompt()).thenApply(result -> null); } else if (options.getPrompt() != null) { - turnContext.sendActivity(options.getPrompt()).join(); + return turnContext.sendActivity(options.getPrompt()).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } /** - * When overridden in a derived class, attempts to recognize the incoming activity. + * When overridden in a derived class, attempts to recognize the incoming + * activity. * - * @param turnContext Context for the current turn of conversation with the user. - * @param state Contains state for the current instance of the prompt on the - * dialog stack. - * @param options A prompt options Object constructed from the options initially - * provided in the call to {@link DialogContext#prompt(String, PromptOptions)} . + * @param turnContext Context for the current turn of conversation with the + * user. + * @param state Contains state for the current instance of the prompt on + * the dialog stack. + * @param options A prompt options Object constructed from the options + * initially provided in the call to + * {@link DialogContext#prompt(String, PromptOptions)} . * - * @return A {@link CompletableFuture} representing the asynchronous operation. + * @return A {@link CompletableFuture} representing the asynchronous operation. * - * If the task is successful, the result describes the result of the recognition attempt. + * If the task is successful, the result describes the result of the + * recognition attempt. */ protected CompletableFuture> onRecognize(TurnContext turnContext, - Map state, PromptOptions options) { + Map state, PromptOptions options) { PromptRecognizerResult result = new PromptRecognizerResult(); result.setSucceeded(true); result.setValue(turnContext.getActivity()); diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/AttachmentPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/AttachmentPrompt.java index d7903050f..958c427d4 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/AttachmentPrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/AttachmentPrompt.java @@ -74,9 +74,9 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } else if (options.getPrompt() != null) { - turnContext.sendActivity(options.getPrompt()).join(); + return turnContext.sendActivity(options.getPrompt()).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ChoicePrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ChoicePrompt.java index 9b8bb126f..e9c411d0c 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ChoicePrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ChoicePrompt.java @@ -245,9 +245,7 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } /** diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ConfirmPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ConfirmPrompt.java index d9f9f003f..402d0f1a5 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ConfirmPrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/ConfirmPrompt.java @@ -255,8 +255,7 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } /** diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/DateTimePrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/DateTimePrompt.java index 83cd57306..38c491542 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/DateTimePrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/DateTimePrompt.java @@ -104,9 +104,9 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } else if (options.getPrompt() != null) { - turnContext.sendActivity(options.getPrompt()).join(); + return turnContext.sendActivity(options.getPrompt()).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/NumberPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/NumberPrompt.java index d95dd2be4..d211d7a17 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/NumberPrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/NumberPrompt.java @@ -138,9 +138,9 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } else if (options.getPrompt() != null) { - turnContext.sendActivity(options.getPrompt()).join(); + return turnContext.sendActivity(options.getPrompt()).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java index ee87f2967..b16caa43b 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/Prompt.java @@ -32,14 +32,15 @@ * Defines the core behavior of prompt dialogs. * * When the prompt ends, it should return a Object that represents the value - * that was prompted for. Use {@link com.microsoft.bot.dialogs.DialogSet#add(Dialog)} or - * {@link com.microsoft.bot.dialogs.ComponentDialog#addDialog(Dialog)} to add a prompt to - * a dialog set or component dialog, respectively. Use + * that was prompted for. Use + * {@link com.microsoft.bot.dialogs.DialogSet#add(Dialog)} or + * {@link com.microsoft.bot.dialogs.ComponentDialog#addDialog(Dialog)} to add a + * prompt to a dialog set or component dialog, respectively. Use * {@link DialogContext#prompt(String, PromptOptions)} or * {@link DialogContext#beginDialog(String, Object)} to start the prompt. If you * start a prompt from a {@link com.microsoft.bot.dialogs.WaterfallStep} in a - * {@link com.microsoft.bot.dialogs.WaterfallDialog}, then the prompt result will be - * available in the next step of the waterfall. + * {@link com.microsoft.bot.dialogs.WaterfallDialog}, then the prompt result + * will be available in the next step of the waterfall. * * @param Type the prompt is created for. */ @@ -61,8 +62,8 @@ public abstract class Prompt extends Dialog { * * The value of dialogId must be unique within the * {@link com.microsoft.bot.dialogs.DialogSet} or - * {@link com.microsoft.bot.dialogs.ComponentDialog} to which the - * prompt is added. + * {@link com.microsoft.bot.dialogs.ComponentDialog} to which + * the prompt is added. */ public Prompt(String dialogId, PromptValidator validator) { super(dialogId); @@ -89,15 +90,12 @@ public Prompt(String dialogId, PromptValidator validator) { public CompletableFuture beginDialog(DialogContext dc, Object options) { if (dc == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "dc cannot be null." - )); + return Async.completeExceptionally(new IllegalArgumentException("dc cannot be null.")); } if (!(options instanceof PromptOptions)) { - return Async.completeExceptionally(new IllegalArgumentException( - "Prompt options are required for Prompt dialogs" - )); + return Async.completeExceptionally( + new IllegalArgumentException("Prompt options are required for Prompt dialogs")); } // Ensure prompts have input hint set @@ -111,7 +109,6 @@ public CompletableFuture beginDialog(DialogContext dc, Object opt.getRetryPrompt().setInputHint(InputHints.EXPECTING_INPUT); } - // Initialize prompt state Map state = dc.getActiveDialog().getState(); state.put(PERSISTED_OPTIONS, opt); @@ -121,10 +118,8 @@ public CompletableFuture beginDialog(DialogContext dc, Object state.put(PERSISTED_STATE, pState); // Send initial prompt - onPrompt(dc.getContext(), - (Map) state.get(PERSISTED_STATE), - (PromptOptions) state.get(PERSISTED_OPTIONS), - false); + onPrompt(dc.getContext(), (Map) state.get(PERSISTED_STATE), + (PromptOptions) state.get(PERSISTED_OPTIONS), false); return CompletableFuture.completedFuture(Dialog.END_OF_TURN); } @@ -145,9 +140,7 @@ public CompletableFuture beginDialog(DialogContext dc, Object public CompletableFuture continueDialog(DialogContext dc) { if (dc == null) { - return Async.completeExceptionally(new IllegalArgumentException( - "dc cannot be null." - )); + return Async.completeExceptionally(new IllegalArgumentException("dc cannot be null.")); } // Don't do anything for non-message activities @@ -159,30 +152,36 @@ public CompletableFuture continueDialog(DialogContext dc) { DialogInstance instance = dc.getActiveDialog(); Map state = (Map) instance.getState().get(PERSISTED_STATE); PromptOptions options = (PromptOptions) instance.getState().get(PERSISTED_OPTIONS); - PromptRecognizerResult recognized = onRecognize(dc.getContext(), state, options).join(); + return onRecognize(dc.getContext(), state, options).thenCompose(recognized -> { + state.put(ATTEMPTCOUNTKEY, (int) state.get(ATTEMPTCOUNTKEY) + 1); + + // Validate the return value + return validateContext(dc, state, options, recognized).thenCompose(isValid -> { + // Return recognized value or re-prompt + if (isValid) { + return dc.endDialog(recognized.getValue()); + } - state.put(ATTEMPTCOUNTKEY, (int) state.get(ATTEMPTCOUNTKEY) + 1); + if (!dc.getContext().getResponded()) { + return onPrompt(dc.getContext(), state, options, true).thenApply(result -> Dialog.END_OF_TURN); + } - // Validate the return value + return CompletableFuture.completedFuture(Dialog.END_OF_TURN); + }); + }); + } + + private CompletableFuture validateContext(DialogContext dc, Map state, + PromptOptions options, PromptRecognizerResult recognized) { Boolean isValid = false; if (validator != null) { - PromptValidatorContext promptContext = new PromptValidatorContext(dc.getContext(), - recognized, state, options); - isValid = validator.promptValidator(promptContext).join(); + PromptValidatorContext promptContext = new PromptValidatorContext(dc.getContext(), recognized, state, + options); + return validator.promptValidator(promptContext); } else if (recognized.getSucceeded()) { isValid = true; } - - // Return recognized value or re-prompt - if (isValid) { - return dc.endDialog(recognized.getValue()); - } - - if (!dc.getContext().getResponded()) { - onPrompt(dc.getContext(), state, options, true); - } - - return CompletableFuture.completedFuture(Dialog.END_OF_TURN); + return CompletableFuture.completedFuture(isValid); } /** @@ -210,8 +209,7 @@ public CompletableFuture resumeDialog(DialogContext dc, Dialog // dialogResume() when the pushed on dialog ends. // To avoid the prompt prematurely ending we need to implement this method and // simply re-prompt the user. - repromptDialog(dc.getContext(), dc.getActiveDialog()).join(); - return CompletableFuture.completedFuture(Dialog.END_OF_TURN); + return repromptDialog(dc.getContext(), dc.getActiveDialog()).thenApply(finalResult -> Dialog.END_OF_TURN); } /** @@ -228,32 +226,31 @@ public CompletableFuture resumeDialog(DialogContext dc, Dialog public CompletableFuture repromptDialog(TurnContext turnContext, DialogInstance instance) { Map state = (Map) instance.getState().get(PERSISTED_STATE); PromptOptions options = (PromptOptions) instance.getState().get(PERSISTED_OPTIONS); - onPrompt(turnContext, state, options, false).join(); - return CompletableFuture.completedFuture(null); + return onPrompt(turnContext, state, options, false).thenApply(result -> null); } /** * Called before an event is bubbled to its parent. * - * This is a good place to perform interception of an event as returning `true` will prevent - * any further bubbling of the event to the dialogs parents and will also prevent any child - * dialogs from performing their default processing. + * This is a good place to perform interception of an event as returning `true` + * will prevent any further bubbling of the event to the dialogs parents and + * will also prevent any child dialogs from performing their default processing. * - * @param dc The dialog context for the current turn of conversation. - * @param e The event being raised. + * @param dc The dialog context for the current turn of conversation. + * @param e The event being raised. * - * @return Whether the event is handled by the current dialog and further processing - * should stop. + * @return Whether the event is handled by the current dialog and further + * processing should stop. */ @Override protected CompletableFuture onPreBubbleEvent(DialogContext dc, DialogEvent e) { - if (e.getName() == DialogEvents.ACTIVITY_RECEIVED - && dc.getContext().getActivity().isType(ActivityTypes.MESSAGE)) { + if (e.getName().equals(DialogEvents.ACTIVITY_RECEIVED) + && dc.getContext().getActivity().isType(ActivityTypes.MESSAGE)) { // Perform base recognition Map state = dc.getActiveDialog().getState(); - PromptRecognizerResult recognized = onRecognize(dc.getContext(), - (Map) state.get(PERSISTED_STATE), (PromptOptions) state.get(PERSISTED_OPTIONS)).join(); - return CompletableFuture.completedFuture(recognized.getSucceeded()); + return onRecognize(dc.getContext(), (Map) state.get(PERSISTED_STATE), + (PromptOptions) state.get(PERSISTED_OPTIONS)) + .thenCompose(recognized -> CompletableFuture.completedFuture(recognized.getSucceeded())); } return CompletableFuture.completedFuture(false); @@ -311,55 +308,57 @@ protected abstract CompletableFuture> onRecognize(Turn * * @return A {@link CompletableFuture} representing the asynchronous operation. * - * If the task is successful, the result contains the updated activity. + * If the task is successful, the result contains the updated activity. */ - protected Activity appendChoices(Activity prompt, String channelId, List choices, - ListStyle style, ChoiceFactoryOptions options) { + protected Activity appendChoices(Activity prompt, String channelId, List choices, ListStyle style, + ChoiceFactoryOptions options) { // Get base prompt text (if any) String text = ""; if (prompt != null && prompt.getText() != null && StringUtils.isNotBlank(prompt.getText())) { - text = prompt.getText(); + text = prompt.getText(); } // Create temporary msg Activity msg; switch (style) { - case INLINE: - msg = ChoiceFactory.inline(choices, text, null, options); - break; - - case LIST: - msg = ChoiceFactory.list(choices, text, null, options); - break; - - case SUGGESTED_ACTION: - msg = ChoiceFactory.suggestedAction(choices, text); - break; - - case HEROCARD: - msg = ChoiceFactory.heroCard(choices, text); - break; - - case NONE: - msg = Activity.createMessageActivity(); - msg.setText(text); - break; - - default: - msg = ChoiceFactory.forChannel(channelId, choices, text, null, options); - break; + case INLINE: + msg = ChoiceFactory.inline(choices, text, null, options); + break; + + case LIST: + msg = ChoiceFactory.list(choices, text, null, options); + break; + + case SUGGESTED_ACTION: + msg = ChoiceFactory.suggestedAction(choices, text); + break; + + case HEROCARD: + msg = ChoiceFactory.heroCard(choices, text); + break; + + case NONE: + msg = Activity.createMessageActivity(); + msg.setText(text); + break; + + default: + msg = ChoiceFactory.forChannel(channelId, choices, text, null, options); + break; } // Update prompt with text, actions and attachments if (prompt != null) { - // clone the prompt the set in the options (note ActivityEx has Properties so this is the safest mechanism) - //prompt = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(prompt)); + // clone the prompt the set in the options (note ActivityEx has Properties so + // this is the safest mechanism) + // prompt = + // JsonConvert.DeserializeObject(JsonConvert.SerializeObject(prompt)); prompt = Activity.clone(prompt); prompt.setText(msg.getText()); if (msg.getSuggestedActions() != null && msg.getSuggestedActions().getActions() != null - && msg.getSuggestedActions().getActions().size() > 0) { + && msg.getSuggestedActions().getActions().size() > 0) { prompt.setSuggestedActions(msg.getSuggestedActions()); } diff --git a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/TextPrompt.java b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/TextPrompt.java index 20869b2a7..a41e04a50 100644 --- a/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/TextPrompt.java +++ b/libraries/bot-dialogs/src/main/java/com/microsoft/bot/dialogs/prompts/TextPrompt.java @@ -73,9 +73,9 @@ protected CompletableFuture onPrompt(TurnContext turnContext, Map null); } else if (options.getPrompt() != null) { - turnContext.sendActivity(options.getPrompt()).join(); + return turnContext.sendActivity(options.getPrompt()).thenApply(result -> null); } return CompletableFuture.completedFuture(null); } diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogManagerTests.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogManagerTests.java index a2f19afb5..490bf7c9b 100644 --- a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogManagerTests.java +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogManagerTests.java @@ -470,7 +470,7 @@ class TestSendActivities implements SendActivitiesHandler { public CompletableFuture invoke(TurnContext context, List activities, Supplier> next) { for (Activity activity : activities) { - if (activity.getType() == ActivityTypes.END_OF_CONVERSATION) { + if (activity.getType().equals(ActivityTypes.END_OF_CONVERSATION)) { _eocSent = activity; break; } diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogTestClient.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogTestClient.java new file mode 100644 index 000000000..7f865da1c --- /dev/null +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/DialogTestClient.java @@ -0,0 +1,217 @@ +package com.microsoft.bot.dialogs; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.AutoSaveStateMiddleware; +import com.microsoft.bot.builder.BotCallbackHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MemoryStorage; +import com.microsoft.bot.builder.Middleware; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.adapters.TestAdapter; +import com.microsoft.bot.schema.Activity; + +public class DialogTestClient { + + private DialogContext dialogContext; + + private DialogTurnResult dialogTurnResult; + + private ConversationState conversationState; + + private final BotCallbackHandler _callback; + private final TestAdapter testAdapter; + + /** + * Initializes a new instance of the {@link DialogTestClient} class. + * + * @param channelId The channelId (see {@link Channels} ) to be used for the test. Use + * {@link Channels#emulator} or {@link Channels#test} if you are uncertain + * of the channel you are targeting. Otherwise, it is recommended that you + * use the id for the channel(s) your bot will be using. Consider writing a + * test case for each channel. + * @param targetDialog The dialog to be tested. This will + * be the root dialog for the test client. + * @param initialDialogOptions (Optional) additional argument(s) to + * pass to the dialog being started. + * @param middlewares (Optional) A list of middlewares to + * be added to the test adapter. + * @param conversationState (Optional) A + * {@link ConversationState} to use in the test client. + */ + public DialogTestClient( + String channelId, + Dialog targetDialog, + Object initialDialogOptions, + List middlewares, + ConversationState conversationState + ) { + if (conversationState == null) { + this.conversationState = new ConversationState(new MemoryStorage()); + } else { + this.conversationState = conversationState; + } + this.testAdapter = new TestAdapter(channelId).use(new AutoSaveStateMiddleware(conversationState)); + + addUserMiddlewares(middlewares); + + StatePropertyAccessor dialogState = getConversationState().createProperty("DialogState"); + + _callback = getDefaultCallback(targetDialog, initialDialogOptions, dialogState); + } + + /** + * Initializes a new instance of the {@link DialogTestClient} class. + * + * @param testAdapter The {@link TestAdapter} to use. + * @param targetDialog The dialog to be tested. This will + * be the root dialog for the test client. + * @param initialDialogOptions (Optional) additional argument(s) to + * pass to the dialog being started. + * @param middlewares (Optional) A list of middlewares to + * be added to the test adapter. + * * @param conversationState (Optional) A + * {@link ConversationState} to use in the test client. + */ + public DialogTestClient( + TestAdapter testAdapter, + Dialog targetDialog, + Object initialDialogOptions, + List middlewares, + ConversationState conversationState + ) { + + if (conversationState == null) { + this.conversationState = new ConversationState(new MemoryStorage()); + } else { + this.conversationState = conversationState; + } + this.testAdapter = testAdapter.use(new AutoSaveStateMiddleware(conversationState)); + + addUserMiddlewares(middlewares); + + StatePropertyAccessor dialogState = getConversationState().createProperty("DialogState"); + + _callback = getDefaultCallback(targetDialog, initialDialogOptions, dialogState); + } + + /** + * Sends an {@link Activity} to the target dialog. + * + * @param activity The activity to send. + * + * @return A {@link CompletableFuture} representing the result of + * the asynchronous operation. + */ + public CompletableFuture sendActivity(Activity activity) { + testAdapter.processActivity(activity, _callback).join(); + return CompletableFuture.completedFuture(getNextReply()); + } + + /** + * Sends a message activity to the target dialog. + * + * @param text The text of the message to send. + * + * @return A {@link CompletableFuture} representing the result of + * the asynchronous operation. + */ + public CompletableFuture sendActivity(String text){ + testAdapter.sendTextToBot(text, _callback).join(); + return CompletableFuture.completedFuture(getNextReply()); + } + + /** + * Gets the next bot response. + * + * @return The next activity in the queue; or null, if the queue + * is empty. + * @param the type. + */ + public T getNextReply() { + return (T) testAdapter.getNextReply(); + } + + private BotCallbackHandler getDefaultCallback( + Dialog targetDialog, + Object initialDialogOptions, + StatePropertyAccessor dialogState + ) { + BotCallbackHandler handler = + (turnContext) -> { + // Ensure dialog state instanceof created and pass it to DialogSet. + dialogState.get(turnContext, () -> new DialogState()); + DialogSet dialogs = new DialogSet(dialogState); + dialogs.add(targetDialog); + + dialogContext = dialogs.createContext(turnContext).join(); + dialogTurnResult = dialogContext.continueDialog().join(); + switch (dialogTurnResult.getStatus()) { + case EMPTY: + dialogTurnResult = dialogContext.beginDialog(targetDialog.getId(), initialDialogOptions).join(); + break; + case COMPLETE: + default: + // Dialog has ended + break; + } + return CompletableFuture.completedFuture(null); + }; + return handler; + } + + private void addUserMiddlewares(List middlewares) { + if (middlewares != null) { + for (Middleware middleware : middlewares) { + testAdapter.use(middleware); + } + } + } + /** + * Gets a reference for the {@link DialogContext} . + * This property will be null until at least one activity is sent to + * {@link DialogTestClient} . + * @return the DialogContext value as a getDialogContext(). + */ + public DialogContext getDialogContext() { + return this.dialogContext; + } + + /** + * Gets a reference for the {@link DialogContext} . + * This property will be null until at least one activity is sent to + * {@link DialogTestClient} . + * @param withDialogContext The DialogContext value. + */ + private void setDialogContext(DialogContext withDialogContext) { + this.dialogContext = withDialogContext; + } + + /** + * Gets the latest {@link DialogTurnResult} for the dialog being tested. + * @return the DialogTurnResult value as a getDialogTurnResult(). + */ + public DialogTurnResult getDialogTurnResult() { + return this.dialogTurnResult; + } + + /** + * Gets the latest {@link DialogTurnResult} for the dialog being tested. + * @param withDialogTurnResult The DialogTurnResult value. + */ + private void setDialogTurnResult(DialogTurnResult withDialogTurnResult) { + this.dialogTurnResult = withDialogTurnResult; + } + + /** + * Gets the latest {@link ConversationState} for + * {@link DialogTestClient} . + * @return the ConversationState value as a getConversationState(). + */ + public ConversationState getConversationState() { + return this.conversationState; + } + +} + diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/LamdbaDialog.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/LamdbaDialog.java index e425793ab..9c84babec 100644 --- a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/LamdbaDialog.java +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/LamdbaDialog.java @@ -39,8 +39,6 @@ public LamdbaDialog(String testName, DialogTestFunction function) { */ @Override public CompletableFuture beginDialog(DialogContext dc, Object options) { - func.runTest(dc).join(); - return dc.endDialog(); + return func.runTest(dc).thenCompose(result -> dc.endDialog()); } - } diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/ReplaceDialogTests.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/ReplaceDialogTests.java index f4b02fb00..579c1336c 100644 --- a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/ReplaceDialogTests.java +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/ReplaceDialogTests.java @@ -137,7 +137,7 @@ public CompletableFuture waterfallStep(WaterfallStepContext st private class ReplaceAction implements WaterfallStep { @Override public CompletableFuture waterfallStep(WaterfallStepContext stepContext) { - if ((String) stepContext.getResult() == "replace") { + if (((String)stepContext.getResult()).equals("replace")) { return stepContext.replaceDialog("SecondDialog"); } else { return stepContext.next(null); diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/SkillDialogTests.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/SkillDialogTests.java new file mode 100644 index 000000000..6c56f035b --- /dev/null +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/SkillDialogTests.java @@ -0,0 +1,705 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.dialogs; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import com.microsoft.bot.builder.AutoSaveStateMiddleware; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MemoryStorage; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.TypedInvokeResponse; +import com.microsoft.bot.builder.adapters.TestAdapter; +import com.microsoft.bot.builder.skills.BotFrameworkClient; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryOptions; +import com.microsoft.bot.builder.skills.SkillConversationReference; +import com.microsoft.bot.connector.Channels; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.Attachment; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationAccount; +import com.microsoft.bot.schema.DeliveryModes; +import com.microsoft.bot.schema.ExpectedReplies; +import com.microsoft.bot.schema.OAuthCard; +import com.microsoft.bot.schema.TokenExchangeResource; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Tests for SkillsDialog. + */ +public class SkillDialogTests { + + @Test + public void ConstructorValidationTests() { + Assert.assertThrows(IllegalArgumentException.class, () -> new SkillDialog(null, null)); + } + + @Test + public void BeginDialogOptionsValidation() { + SkillDialogOptions dialogOptions = new SkillDialogOptions(); + SkillDialog sut = new SkillDialog(dialogOptions, null); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + try { + DialogTestClient client = new DialogTestClient(Channels.TEST, sut, null, null, null); + client.sendActivity("irrelevant").join(); + } catch (CompletionException ex) { + throw ex.getCause(); + } + }); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + try { + DialogTestClient client = new DialogTestClient(Channels.TEST, sut, new HashMap(), null, + null); + client.sendActivity("irrelevant").join(); + } catch (CompletionException ex) { + throw ex.getCause(); + } + }); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + try { + DialogTestClient client = new DialogTestClient(Channels.TEST, sut, new BeginSkillDialogOptions(), null, + null); + client.sendActivity("irrelevant").join(); + } catch (CompletionException ex) { + throw ex.getCause(); + } + }); + } + + @Test + public void BeginDialogCallsSkill_null() { + beginDialogCallsSkill(null); + } + + @Test + public void BeginDialogCallsSkill_Expect_Replies() { + beginDialogCallsSkill(DeliveryModes.EXPECT_REPLIES.toString()); + } + + class MockFrameworkClient extends BotFrameworkClient { + + int returnStatus = 200; + ExpectedReplies expectedReplies = null;; + + MockFrameworkClient() { + + } + + MockFrameworkClient(int returnStatus) { + this.returnStatus = returnStatus; + } + + MockFrameworkClient(int returnStatus, ExpectedReplies expectedReplies) { + this.returnStatus = returnStatus; + this.expectedReplies = expectedReplies; + } + + @Override + public CompletableFuture> postActivity( + String fromBotId, + String toBotId, + URI toUri, + URI serviceUrl, + String conversationId, + Activity activity, + Class type + ) { + fromBotIdSent = fromBotId; + toBotIdSent = toBotId; + toUriSent = toUri; + activitySent = activity; + List activities = new ArrayList(); + activities.add(MessageFactory.text("dummy activity")); + ExpectedReplies activityList = new ExpectedReplies(activities); + if (expectedReplies != null) { + TypedInvokeResponse response = new TypedInvokeResponse(returnStatus, expectedReplies); + return CompletableFuture.completedFuture(response); + } else { + TypedInvokeResponse response = new TypedInvokeResponse(returnStatus, activityList); + return CompletableFuture.completedFuture(response); + } + } + + public String fromBotIdSent; + public String toBotIdSent; + public URI toUriSent; + public Activity activitySent; + } + + class MockFrameworkClientExtended extends BotFrameworkClient { + + int returnStatus = 200; + ExpectedReplies expectedReplies = null; + int iterationCount = 0; + + MockFrameworkClientExtended(int returnStatus, ExpectedReplies expectedReplies) { + this.returnStatus = returnStatus; + this.expectedReplies = expectedReplies; + } + + @Override + public CompletableFuture> postActivity( + String fromBotId, + String toBotId, + URI toUri, + URI serviceUrl, + String conversationId, + Activity activity, + Class type + ) { + fromBotIdSent = fromBotId; + toBotIdSent = toBotId; + toUriSent = toUri; + activitySent = activity; + List activities = new ArrayList(); + activities.add(MessageFactory.text("dummy activity")); + ExpectedReplies activityList = new ExpectedReplies(activities); + if (iterationCount == 0) { + TypedInvokeResponse response = new TypedInvokeResponse(200, expectedReplies); + iterationCount++; + return CompletableFuture.completedFuture(response); + } else { + TypedInvokeResponse response = new TypedInvokeResponse(returnStatus, null); + return CompletableFuture.completedFuture(response); + } + } + + public String fromBotIdSent; + public String toBotIdSent; + public URI toUriSent; + public Activity activitySent; + } + + + + + public void beginDialogCallsSkill(String deliveryMode) { + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(); + + // Use Memory for conversation state + ConversationState conversationState = new ConversationState(new MemoryStorage()); + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + + // Create the SkillDialogInstance and the activity to send. + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = Activity.createMessageActivity(); + activityToSend.setDeliveryMode(deliveryMode); + activityToSend.setText(UUID.randomUUID().toString()); + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + Channels.TEST, + sut, + skillDialogOptions, + null, + conversationState); + + Assert.assertEquals(0, ((SimpleConversationIdFactory) dialogOptions.getConversationIdFactory()).createCount); + + // Send something to the dialog to start it + client.sendActivity("irrelevant").join(); + + // Assert results and data sent to the SkillClient for fist turn + Assert.assertEquals(1, ((SimpleConversationIdFactory) dialogOptions.getConversationIdFactory()).createCount); + Assert.assertEquals(dialogOptions.getBotId(), mockSkillClient.fromBotIdSent); + Assert.assertEquals(dialogOptions.getSkill().getAppId(), mockSkillClient.toBotIdSent); + Assert.assertEquals(dialogOptions.getSkill().getSkillEndpoint().toString(), + mockSkillClient.toUriSent.toString()); + Assert.assertEquals(activityToSend.getText(), mockSkillClient.activitySent.getText()); + Assert.assertEquals(DialogTurnStatus.WAITING, client.getDialogTurnResult().getStatus()); + + // Send a second message to continue the dialog + client.sendActivity("Second message").join(); + Assert.assertEquals(1, ((SimpleConversationIdFactory) dialogOptions.getConversationIdFactory()).createCount); + + // Assert results for second turn + Assert.assertEquals("Second message", mockSkillClient.activitySent.getText()); + Assert.assertEquals(DialogTurnStatus.WAITING, client.getDialogTurnResult().getStatus()); + + // Send EndOfConversation to the dialog + client.sendActivity(Activity.createEndOfConversationActivity()).join(); + + // Assert we are done. + Assert.assertEquals(DialogTurnStatus.COMPLETE, client.getDialogTurnResult().getStatus()); + } + + @Test + public void ShouldHandleInvokeActivities() { + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(); + + // Use Memory for conversation state + ConversationState conversationState = new ConversationState(new MemoryStorage()); + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + + Activity activityToSend = Activity.createInvokeActivity(); + activityToSend.setName(UUID.randomUUID().toString()); + + // Create the SkillDialogInstance and the activity to send. + SkillDialog sut = new SkillDialog(dialogOptions, null); + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + Channels.TEST, + sut, + skillDialogOptions, + null, + conversationState); + + // Send something to the dialog to start it + client.sendActivity("irrelevant").join(); + + // Assert results and data sent to the SkillClient for fist turn + Assert.assertEquals(dialogOptions.getBotId(), mockSkillClient.fromBotIdSent); + Assert.assertEquals(dialogOptions.getSkill().getAppId(), mockSkillClient.toBotIdSent); + Assert.assertEquals(dialogOptions.getSkill().getSkillEndpoint().toString(), + mockSkillClient.toUriSent.toString()); + Assert.assertEquals(activityToSend.getName(), mockSkillClient.activitySent.getName()); + Assert.assertEquals(DeliveryModes.EXPECT_REPLIES.toString(), mockSkillClient.activitySent.getDeliveryMode()); + Assert.assertEquals(activityToSend.getText(), mockSkillClient.activitySent.getText()); + Assert.assertEquals(DialogTurnStatus.WAITING, client.getDialogTurnResult().getStatus()); + + // Send a second message to continue the dialog + client.sendActivity("Second message").join(); + + // Assert results for second turn + Assert.assertEquals("Second message", mockSkillClient.activitySent.getText()); + Assert.assertEquals(DialogTurnStatus.WAITING, client.getDialogTurnResult().getStatus()); + + // Send EndOfConversation to the dialog + client.sendActivity(Activity.createEndOfConversationActivity()).join(); + + // Assert we are done. + Assert.assertEquals(DialogTurnStatus.COMPLETE, client.getDialogTurnResult().getStatus()); + } + + @Test + public void CancelDialogSendsEoC() { + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(); + + // Use Memory for conversation state + ConversationState conversationState = new ConversationState(new MemoryStorage()); + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + + Activity activityToSend = Activity.createMessageActivity(); + activityToSend.setName(UUID.randomUUID().toString()); + + // Create the SkillDialogInstance and the activity to send. + SkillDialog sut = new SkillDialog(dialogOptions, null); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + Channels.TEST, + sut, + skillDialogOptions, + null, + conversationState); + + // Send something to the dialog to start it + client.sendActivity("irrelevant").join(); + + // Cancel the dialog so it sends an EoC to the skill + client.getDialogContext().cancelAllDialogs(); + + Assert.assertEquals(ActivityTypes.END_OF_CONVERSATION, mockSkillClient.activitySent.getType()); + } + + @Test + public void ShouldThrowHttpExceptionOnPostFailure() { + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(500); + + // Use Memory for conversation state + ConversationState conversationState = new ConversationState(new MemoryStorage()); + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + + Activity activityToSend = Activity.createMessageActivity(); + activityToSend.setName(UUID.randomUUID().toString()); + + // Create the SkillDialogInstance and the activity to send. + SkillDialog sut = new SkillDialog(dialogOptions, null); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + Channels.TEST, + sut, + skillDialogOptions, + null, + conversationState); + + // Send something to the dialog + Assert.assertThrows(Exception.class, () -> client.sendActivity("irrelevant")); + } + + @Test + public void ShouldInterceptOAuthCardsForSso() { + String connectionName = "connectionName"; + List replyList = new ArrayList(); + replyList.add(createOAuthCardAttachmentActivity("https://test")); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(200, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + TestAdapter testAdapter = new TestAdapter(Channels.TEST) + .use(new AutoSaveStateMiddleware(conversationState)); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, connectionName); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = createSendActivity(); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + testAdapter, + sut, + skillDialogOptions, + null, + conversationState); + + testAdapter.addExchangeableToken(connectionName, Channels.TEST, "user1", "https://test", "https://test1"); + Activity finalActivity = client.sendActivity("irrelevant").join(); + Assert.assertNull(finalActivity); + } + + @Test + public void ShouldNotInterceptOAuthCardsForEmptyConnectionName() { + String connectionName = "connectionName"; + List replyList = new ArrayList(); + replyList.add(createOAuthCardAttachmentActivity("https://test")); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(200, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + TestAdapter testAdapter = new TestAdapter(Channels.TEST) + .use(new AutoSaveStateMiddleware(conversationState)); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = createSendActivity(); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + testAdapter, + sut, + skillDialogOptions, + null, + conversationState); + + testAdapter.addExchangeableToken(connectionName, Channels.TEST, "user1", "https://test", "https://test1"); + Activity finalActivity = client.sendActivity("irrelevant").join(); + Assert.assertNotNull(finalActivity); + Assert.assertTrue(finalActivity.getAttachments().size() == 1); + } + + @Test + public void ShouldNotInterceptOAuthCardsForEmptyToken() { + List replyList = new ArrayList(); + replyList.add(createOAuthCardAttachmentActivity("https://test")); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(200, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + TestAdapter testAdapter = new TestAdapter(Channels.TEST) + .use(new AutoSaveStateMiddleware(conversationState)); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = createSendActivity(); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + testAdapter, + sut, + skillDialogOptions, + null, + conversationState); + + Activity finalActivity = client.sendActivity("irrelevant").join(); + Assert.assertNotNull(finalActivity); + Assert.assertTrue(finalActivity.getAttachments().size() == 1); + } + + @Test + public void ShouldNotInterceptOAuthCardsForTokenException() { + String connectionName = "connectionName"; + List replyList = new ArrayList(); + replyList.add(createOAuthCardAttachmentActivity("https://test")); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(200, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + TestAdapter testAdapter = new TestAdapter(Channels.TEST) + .use(new AutoSaveStateMiddleware(conversationState)); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = createSendActivity(); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + testAdapter, + sut, + skillDialogOptions, + null, + conversationState); + + testAdapter.throwOnExchangeRequest(connectionName, Channels.TEST, "user1", "https://test"); + Activity finalActivity = client.sendActivity("irrelevant").join(); + Assert.assertNotNull(finalActivity); + Assert.assertTrue(finalActivity.getAttachments().size() == 1); + } + + @Test + public void ShouldNotInterceptOAuthCardsForBadRequest() { + List replyList = new ArrayList(); + replyList.add(createOAuthCardAttachmentActivity("https://test")); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClientExtended mockSkillClient = new MockFrameworkClientExtended(409, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + TestAdapter testAdapter = new TestAdapter(Channels.TEST) + .use(new AutoSaveStateMiddleware(conversationState)); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, null); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = createSendActivity(); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + testAdapter, + sut, + skillDialogOptions, + null, + conversationState); + + Activity finalActivity = client.sendActivity("irrelevant").join(); + Assert.assertNotNull(finalActivity); + Assert.assertTrue(finalActivity.getAttachments().size() == 1); + } + + @Test + public void EndOfConversationFromExpectRepliesCallsDeleteConversationReference() { + List replyList = new ArrayList(); + replyList.add(Activity.createEndOfConversationActivity()); + ExpectedReplies firstResponse = new ExpectedReplies(); + firstResponse.setActivities(replyList); + + // Create a mock skill client to intercept calls and capture what is sent. + MockFrameworkClient mockSkillClient = new MockFrameworkClient(200, firstResponse); + + ConversationState conversationState = new ConversationState(new MemoryStorage()); + + SkillDialogOptions dialogOptions = createSkillDialogOptions(conversationState, mockSkillClient, ""); + SkillDialog sut = new SkillDialog(dialogOptions, null); + Activity activityToSend = Activity.createMessageActivity(); + activityToSend.setDeliveryMode(DeliveryModes.EXPECT_REPLIES.toString()); + activityToSend.setText(UUID.randomUUID().toString()); + + BeginSkillDialogOptions skillDialogOptions = new BeginSkillDialogOptions(); + skillDialogOptions.setActivity(activityToSend); + DialogTestClient client = new DialogTestClient( + Channels.TEST, + sut, + skillDialogOptions, + null, + conversationState); + + // Send something to the dialog to start it + client.sendActivity("hello"); + + SimpleConversationIdFactory factory = null; + if (dialogOptions.getConversationIdFactory() != null + && dialogOptions.getConversationIdFactory() instanceof SimpleConversationIdFactory){ + factory = (SimpleConversationIdFactory) dialogOptions.getConversationIdFactory(); + } + Assert.assertNotNull(factory); + Assert.assertEquals(factory.getConversationRefs().size(), 0); + Assert.assertEquals(1, factory.getCreateCount()); + } + + + private static Activity createOAuthCardAttachmentActivity(String uri) { + + OAuthCard oauthCard = new OAuthCard(); + TokenExchangeResource tokenExchangeResource = new TokenExchangeResource(); + tokenExchangeResource.setUri(uri); + oauthCard.setTokenExchangeResource(tokenExchangeResource); + Attachment attachment = new Attachment(); + attachment.setContentType(OAuthCard.CONTENTTYPE); + attachment.setContent(oauthCard); + + Activity attachmentActivity = MessageFactory.attachment(attachment); + ConversationAccount conversationAccount = new ConversationAccount(); + conversationAccount.setId(UUID.randomUUID().toString()); + attachmentActivity.setConversation(conversationAccount); + attachmentActivity.setFrom(new ChannelAccount("blah", "name")); + + return attachmentActivity; + } + + /** + * Helper to create a {@link SkillDialogOptions} for the skillDialog. + * + * @param conversationState The conversation state Object. + * @param mockSkillClient The skill client mock. + * + * @return A Skill Dialog Options Object. + */ + private SkillDialogOptions createSkillDialogOptions(ConversationState conversationState, + BotFrameworkClient mockSkillClient, String connectionName) { + SkillDialogOptions dialogOptions = new SkillDialogOptions(); + dialogOptions.setBotId(UUID.randomUUID().toString()); + try { + dialogOptions.setSkillHostEndpoint(new URI("http://test.contoso.com/skill/messages")); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + dialogOptions.setConversationIdFactory(new SimpleConversationIdFactory()); + dialogOptions.setConversationState(conversationState); + dialogOptions.setSkillClient(mockSkillClient); + BotFrameworkSkill skill = new BotFrameworkSkill(); + skill.setAppId(UUID.randomUUID().toString()); + try { + skill.setSkillEndpoint(new URI("http://testskill.contoso.com/api/messages")); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + dialogOptions.setSkill(skill); + dialogOptions.setConnectionName(connectionName); + return dialogOptions; + } + + // private static Mock CreateMockSkillClient( + // Action + // captureAction, + // int returnStatus=200,ListexpectedReplies) { + // var mockSkillClient=new Mock();var activityList=new + // ExpectedReplies(expectedReplies??new + // List{MessageFactory.Text("dummy activity")}); + + // if(captureAction!=null){mockSkillClient.Setup(x->x.PostActivity(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).Returns(Task.FromResult(new + // InvokeResponse{Status=returnStatus,Body=activityList})).Callback(captureAction);}else{mockSkillClient.Setup(x->x.PostActivity(It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny(),It.IsAny())).Returns(Task.FromResult(new + // InvokeResponse{Status=returnStatus,Body=activityList}));} + + // return mockSkillClient; + // } + + private Activity createSendActivity() { + Activity activityToSend = Activity.createMessageActivity(); + activityToSend.setDeliveryMode(DeliveryModes.EXPECT_REPLIES.toString()); + activityToSend.setText(UUID.randomUUID().toString()); + return activityToSend; + } + + /** + * Simple factory to that extends SkillConversationIdFactoryBase. + */ + protected class SimpleConversationIdFactory extends SkillConversationIdFactoryBase { + + // Helper property to assert how many times instanceof + // CreateSkillConversationIdAsync called. + private int createCount; + private Map conversationRefs = new HashMap(); + + protected SimpleConversationIdFactory() { + + } + + @Override + public CompletableFuture createSkillConversationId(SkillConversationIdFactoryOptions options) { + createCount++; + + String key = Integer.toString(String.format("%s%s", options.getActivity().getConversation().getId(), + options.getActivity().getServiceUrl()).hashCode()); + SkillConversationReference skillConversationReference = new SkillConversationReference(); + skillConversationReference.setConversationReference(options.getActivity().getConversationReference()); + skillConversationReference.setOAuthScope(options.getFromBotOAuthScope()); + conversationRefs.put(key, skillConversationReference); + return CompletableFuture.completedFuture(key); + } + + @Override + public CompletableFuture getSkillConversationReference(String skillConversationId) { + return CompletableFuture.completedFuture(conversationRefs.get(skillConversationId)); + } + + @Override + public CompletableFuture deleteConversationReference(String skillConversationId) { + + conversationRefs.remove(skillConversationId); + return CompletableFuture.completedFuture(null); + } + + /** + * @return the ConversationRefs value as a Map. + */ + public Map getConversationRefs() { + return this.conversationRefs; + } + + /** + * @param withConversationRefs The ConversationRefs value. + */ + private void setConversationRefs(Map withConversationRefs) { + this.conversationRefs = withConversationRefs; + } + + /** + * @return the CreateCount value as a int. + */ + public int getCreateCount() { + return this.createCount; + } + + /** + * @param withCreateCount The CreateCount value. + */ + private void setCreateCount(int withCreateCount) { + this.createCount = withCreateCount; + } + } +} diff --git a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/ActivityPromptTests.java b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/ActivityPromptTests.java index e0038d656..85ba09df3 100644 --- a/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/ActivityPromptTests.java +++ b/libraries/bot-dialogs/src/test/java/com/microsoft/bot/dialogs/prompts/ActivityPromptTests.java @@ -279,7 +279,7 @@ public CompletableFuture promptValidator(PromptValidatorContext 0); Activity activity = promptContext.getRecognized().getValue(); - if (activity.getType() == ActivityTypes.EVENT) { + if (activity.getType().equals(ActivityTypes.EVENT)) { if ((int) activity.getValue() == 2) { promptContext.getRecognized().setValue(MessageFactory.text(activity.getValue().toString())); return CompletableFuture.completedFuture(true); diff --git a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpAdapter.java b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpAdapter.java index 007f461ad..7ab0b0a9d 100644 --- a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpAdapter.java +++ b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpAdapter.java @@ -6,6 +6,7 @@ import com.microsoft.bot.builder.Bot; import com.microsoft.bot.builder.BotFrameworkAdapter; import com.microsoft.bot.builder.InvokeResponse; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; import com.microsoft.bot.connector.authentication.ChannelProvider; import com.microsoft.bot.connector.authentication.ChannelValidation; import com.microsoft.bot.connector.authentication.CredentialProvider; @@ -43,6 +44,35 @@ public BotFrameworkHttpAdapter(Configuration withConfiguration) { } } + /** + * Construct with a Configuration. This will create a CredentialProvider and + * ChannelProvider based on configuration values. + * + * @param withConfiguration The Configuration to use. + * @param withAuthenticationConfiguration The AuthenticationConfiguration to use. + * + * @see ClasspathPropertiesConfiguration + */ + public BotFrameworkHttpAdapter( + Configuration withConfiguration, + AuthenticationConfiguration withAuthenticationConfiguration + ) { + super( + new ConfigurationCredentialProvider(withConfiguration), + withAuthenticationConfiguration, + new ConfigurationChannelProvider(withConfiguration), + null, + null + ); + + String openIdEndPoint = withConfiguration.getProperty("BotOpenIdMetadata"); + if (!StringUtils.isEmpty(openIdEndPoint)) { + // Indicate which Cloud we are using, for example, Public or Sovereign. + ChannelValidation.setOpenIdMetaDataUrl(openIdEndPoint); + GovernmentChannelValidation.setOpenIdMetaDataUrl(openIdEndPoint); + } + } + /** * Constructs with CredentialProvider and ChannelProvider. * diff --git a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpClient.java b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpClient.java new file mode 100644 index 000000000..e7cea3c96 --- /dev/null +++ b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/BotFrameworkHttpClient.java @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.integration; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.connector.authentication.MicrosoftGovernmentAppCredentials; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; + +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.bot.builder.TypedInvokeResponse; +import com.microsoft.bot.builder.skills.BotFrameworkClient; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.AppCredentials; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationAccount; +import com.microsoft.bot.schema.ConversationReference; +import com.microsoft.bot.schema.RoleTypes; +import com.microsoft.bot.schema.Serialization; + +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.net.URI; + +/** + * Class for posting activities securely to a bot using BotFramework HTTP + * protocol. + * + * This class can be used to securely post activities to a bot using the Bot + * Framework HTTP protocol. There are 2 usage patterns:* Forwarding activity to + * a Skill (Bot -> Bot as a Skill) which is done via PostActivity(fromBotId, + * toBotId, endpoint, serviceUrl, activity);* Posting an activity to yourself + * (External service -> Bot) which is done via PostActivity(botId, endpoint, + * activity)The latter is used by external services such as webjobs that need to + * post activities to the bot using the bots own credentials. + */ +public class BotFrameworkHttpClient extends BotFrameworkClient { + + private static Map appCredentialMapCache = new HashMap();; + + private ChannelProvider channelProvider; + + private CredentialProvider credentialProvider; + + private OkHttpClient httpClient; + + /** + * Initializes a new instance of the {@link BotFrameworkHttpClient} class. + * + * @param credentialProvider An instance of {@link CredentialProvider} . + * @param channelProvider An instance of {@link ChannelProvider} . + */ + public BotFrameworkHttpClient(CredentialProvider credentialProvider, ChannelProvider channelProvider) { + + if (credentialProvider == null) { + throw new IllegalArgumentException("credentialProvider cannot be null."); + } + this.credentialProvider = credentialProvider; + this.channelProvider = channelProvider; + this.httpClient = new OkHttpClient(); + } + + /** + * Forwards an activity to a skill (bot). + * + * NOTE: Forwarding an activity to a skill will flush UserState and + * ConversationState changes so that skill has accurate state. + * + * @param fromBotId The MicrosoftAppId of the bot sending the activity. + * @param toBotId The MicrosoftAppId of the bot receiving the activity. + * @param toUrl The URL of the bot receiving the activity. + * @param serviceUrl The callback Url for the skill host. + * @param conversationId A conversation D to use for the conversation with the + * skill. + * @param activity activity to forward. + * + * @return task with optional invokeResponse. + */ + @Override + public CompletableFuture> postActivity(String fromBotId, String toBotId, + URI toUrl, URI serviceUrl, String conversationId, Activity activity, Class type) { + + return getAppCredentials(fromBotId, toBotId).thenCompose(appCredentials -> { + if (appCredentials == null) { + return Async.completeExceptionally( + new Exception(String.format("Unable to get appCredentials to connect to the skill"))); + } + + // Get token for the skill call + return getToken(appCredentials).thenCompose(token -> { + // Clone the activity so we can modify it before sending without impacting the + // original Object. + Activity activityClone = Activity.clone(activity); + + ConversationAccount conversationAccount = new ConversationAccount(); + conversationAccount.setId(activityClone.getConversation().getId()); + conversationAccount.setName(activityClone.getConversation().getName()); + conversationAccount.setConversationType(activityClone.getConversation().getConversationType()); + conversationAccount.setAadObjectId(activityClone.getConversation().getAadObjectId()); + conversationAccount.setIsGroup(activityClone.getConversation().isGroup()); + for (String key : conversationAccount.getProperties().keySet()) { + activityClone.setProperties(key, conversationAccount.getProperties().get(key)); + } + conversationAccount.setRole(activityClone.getConversation().getRole()); + conversationAccount.setTenantId(activityClone.getConversation().getTenantId()); + + ConversationReference conversationReference = new ConversationReference(); + conversationReference.setServiceUrl(activityClone.getServiceUrl()); + conversationReference.setActivityId(activityClone.getId()); + conversationReference.setChannelId(activityClone.getChannelId()); + conversationReference.setLocale(activityClone.getLocale()); + conversationReference.setConversation(conversationAccount); + + activityClone.setRelatesTo(conversationReference); + activityClone.getConversation().setId(conversationId); + activityClone.setServiceUrl(serviceUrl.toString()); + if (activityClone.getRecipient() == null) { + activityClone.setRecipient(new ChannelAccount()); + } + activityClone.getRecipient().setRole(RoleTypes.SKILL); + + return securePostActivity(toUrl, activityClone, token, type); + }); + }); + } + + private CompletableFuture getToken(AppCredentials appCredentials) { + // Get token for the skill call + if (appCredentials == MicrosoftAppCredentials.empty()) { + return CompletableFuture.completedFuture(null); + } else { + return appCredentials.getToken(); + } + } + + /** + * Post Activity to the bot using the bot's credentials. + * + * @param botId The MicrosoftAppId of the bot. + * @param botEndpoint The URL of the bot. + * @param activity Activity to post. + * @param type Type of . + * @param Type of expected TypedInvokeResponse. + * + * @return InvokeResponse. + */ + public CompletableFuture> postActivity(String botId, URI botEndpoint, + Activity activity, Class type) { + + // From BotId -> BotId + return getAppCredentials(botId, botId).thenCompose(appCredentials -> { + if (appCredentials == null) { + return Async.completeExceptionally( + new Exception(String.format("Unable to get appCredentials for the bot Id=%s", botId))); + } + + return getToken(appCredentials).thenCompose(token -> { + // post the activity to the url using the bot's credentials. + return securePostActivity(botEndpoint, activity, token, type); + }); + }); + } + + /** + * Logic to build an {@link AppCredentials} Object to be used to acquire tokens + * for this getHttpClient(). + * + * @param appId The application id. + * @param oAuthScope The optional OAuth scope. + * + * @return The app credentials to be used to acquire tokens. + */ + protected CompletableFuture buildCredentials(String appId, String oAuthScope) { + return getCredentialProvider().getAppPassword(appId).thenCompose(appPassword -> { + AppCredentials appCredentials = channelProvider != null && getChannelProvider().isGovernment() + ? new MicrosoftGovernmentAppCredentials(appId, appPassword, null, oAuthScope) + : new MicrosoftAppCredentials(appId, appPassword, null, oAuthScope); + return CompletableFuture.completedFuture(appCredentials); + }); + } + + private CompletableFuture> securePostActivity(URI toUrl, + Activity activity, String token, Class type) { + String jsonContent = ""; + try { + ObjectMapper mapper = new JacksonAdapter().serializer(); + jsonContent = mapper.writeValueAsString(activity); + } catch (JsonProcessingException e) { + return Async.completeExceptionally( + new RuntimeException("securePostActivity: Unable to serialize the Activity")); + } + + try { + RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), jsonContent); + Request request = buildRequest(toUrl, body, token); + Response response = httpClient.newCall(request).execute(); + + T result = Serialization.getAs(response.body().string(), type); + TypedInvokeResponse returnValue = new TypedInvokeResponse(response.code(), result); + return CompletableFuture.completedFuture(returnValue); + } catch (IOException e) { + return Async.completeExceptionally(e); + } + } + + private Request buildRequest(URI url, RequestBody body, String token) { + + HttpUrl.Builder httpBuilder = HttpUrl.parse(url.toString()).newBuilder(); + + Request.Builder requestBuilder = new Request.Builder().url(httpBuilder.build()); + if (token != null) { + requestBuilder.addHeader("Authorization", String.format("Bearer %s", token)); + } + requestBuilder.post(body); + return requestBuilder.build(); + } + + /** + * Gets the application credentials. App Credentials are cached so as to ensure + * we are not refreshing token every time. + * + * @param appId The application identifier (AAD Id for the bot). + * @param oAuthScope The scope for the token, skills will use the Skill App Id. + * + * @return App credentials. + */ + private CompletableFuture getAppCredentials(String appId, String oAuthScope) { + if (StringUtils.isEmpty(appId)) { + return CompletableFuture.completedFuture(MicrosoftAppCredentials.empty()); + } + + // If the credentials are in the cache, retrieve them from there + String cacheKey = String.format("%s%s", appId, oAuthScope); + AppCredentials appCredentials = null; + appCredentials = appCredentialMapCache.get(cacheKey); + if (appCredentials != null) { + return CompletableFuture.completedFuture(appCredentials); + } + + // Credentials not found in cache, build them + return buildCredentials(appId, String.format("%s/.default", oAuthScope)).thenCompose(credentials -> { + // Cache the credentials for later use + appCredentialMapCache.put(cacheKey, credentials); + return CompletableFuture.completedFuture(credentials); + }); + } + + /** + * Gets the Cache for appCredentials to speed up token acquisition (a token is + * not requested unless is expired). AppCredentials are cached using appId + + * scope (this last parameter is only used if the app credentials are used to + * call a skill). + * + * @return the AppCredentialMapCache value as a static + * ConcurrentDictionary. + */ + protected static Map getAppCredentialMapCache() { + return appCredentialMapCache; + } + + /** + * Gets the channel provider for this adapter. + * + * @return the ChannelProvider value as a getChannelProvider(). + */ + protected ChannelProvider getChannelProvider() { + return this.channelProvider; + } + + /** + * Gets the credential provider for this adapter. + * + * @return the CredentialProvider value as a getCredentialProvider(). + */ + protected CredentialProvider getCredentialProvider() { + return this.credentialProvider; + } + + /** + * Gets the HttpClient for this adapter. + * + * @return the OkhttpClient value as a getHttpClient(). + */ + public OkHttpClient getHttpClient() { + return httpClient; + } +} diff --git a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/ClasspathPropertiesConfiguration.java b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/ClasspathPropertiesConfiguration.java index cb0ff880d..ecf3fdb2f 100644 --- a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/ClasspathPropertiesConfiguration.java +++ b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/ClasspathPropertiesConfiguration.java @@ -53,4 +53,20 @@ public String getProperty(String key) { public Properties getProperties() { return this.properties; } + + /** + * Returns an array of values from an entry that is comma delimited. + * @param key The property name. + * @return The property values as a String array. + */ + @Override + public String[] getProperties(String key) { + String baseProperty = properties.getProperty(key); + if (baseProperty != null) { + String[] splitProperties = baseProperty.split(","); + return splitProperties; + } else { + return null; + } + } } diff --git a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/Configuration.java b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/Configuration.java index dda35a4a8..7a8d67fde 100644 --- a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/Configuration.java +++ b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/Configuration.java @@ -23,4 +23,11 @@ public interface Configuration { * @return The Properties in the Configuration. */ Properties getProperties(); + + /** + * Returns an Array of Properties that are in the Configuration. + * @param key The property name. + * @return The property values. + */ + String[] getProperties(String key); } diff --git a/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/SkillHttpClient.java b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/SkillHttpClient.java new file mode 100644 index 000000000..2d42006f5 --- /dev/null +++ b/libraries/bot-integration-core/src/main/java/com/microsoft/bot/integration/SkillHttpClient.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.integration; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.builder.TypedInvokeResponse; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryOptions; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.connector.authentication.AuthenticationConstants; +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.connector.authentication.GovernmentAuthenticationConstants; + +/** + * A {@link BotFrameworkHttpClient} specialized for Skills that encapsulates + * Conversation ID generation. + */ +public class SkillHttpClient extends BotFrameworkHttpClient { + + private final SkillConversationIdFactoryBase conversationIdFactory; + + /** + * Initializes a new instance of the {@link SkillHttpClient} class. + * + * @param credentialProvider An instance of {@link CredentialProvider}. + * @param conversationIdFactory An instance of a class derived from + * {@link SkillConversationIdFactoryBase}. + * @param channelProvider An instance of {@link ChannelProvider}. + */ + public SkillHttpClient(CredentialProvider credentialProvider, SkillConversationIdFactoryBase conversationIdFactory, + ChannelProvider channelProvider) { + super(credentialProvider, channelProvider); + this.conversationIdFactory = conversationIdFactory; + } + + /** + * Uses the SkillConversationIdFactory to create or retrieve a Skill + * Conversation Id, and sends the activity. + * + * @param originatingAudience The oauth audience scope, used during token + * retrieval. (Either + * https://api.getbotframework().com or bot app id.) + * @param fromBotId The MicrosoftAppId of the bot sending the + * activity. + * @param toSkill The skill to create the conversation Id for. + * @param callbackUrl The callback Url for the skill host. + * @param activity The activity to send. + * @param type Type of T required due to type erasure of generics + * in Java. + * @param Type of expected TypedInvokeResponse. + * + * @return task with invokeResponse. + */ + public CompletableFuture> postActivity(String originatingAudience, + String fromBotId, BotFrameworkSkill toSkill, URI callbackUrl, Activity activity, Class type) { + return getSkillConversationId(originatingAudience, fromBotId, toSkill, activity) + .thenCompose(skillConversationId -> { + return postActivity(fromBotId, toSkill.getAppId(), toSkill.getSkillEndpoint(), callbackUrl, + skillConversationId, activity, type); + }); + + } + + private CompletableFuture getSkillConversationId(String originatingAudience, String fromBotId, + BotFrameworkSkill toSkill, Activity activity) { + try { + SkillConversationIdFactoryOptions options = new SkillConversationIdFactoryOptions(); + options.setFromBotOAuthScope(originatingAudience); + options.setFromBotId(fromBotId); + options.setActivity(activity); + options.setBotFrameworkSkill(toSkill); + return conversationIdFactory.createSkillConversationId(options); + } catch (Exception ex) { + // Attempt to create the ID using deprecated method. + return conversationIdFactory.createSkillConversationId(activity.getConversationReference()); + } + } + + /** + * Forwards an activity to a skill (bot). + * + * @param fromBotId The MicrosoftAppId of the bot sending the activity. + * @param toSkill An instance of {@link BotFrameworkSkill} . + * @param callbackUrl The callback Uri. + * @param activity activity to forward. + * @param type type of T + * @param Type of expected TypedInvokeResponse. + * + * @return task with optional invokeResponse of type T. + */ + public CompletableFuture> postActivity(String fromBotId, + BotFrameworkSkill toSkill, URI callbackUrl, Activity activity, Class type) { + String originatingAudience = getChannelProvider() != null && getChannelProvider().isGovernment() + ? GovernmentAuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + : AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE; + return postActivity(originatingAudience, fromBotId, toSkill, callbackUrl, activity, type); + } +} diff --git a/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/BotDependencyConfiguration.java b/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/BotDependencyConfiguration.java index adccd057e..025a7de73 100644 --- a/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/BotDependencyConfiguration.java +++ b/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/BotDependencyConfiguration.java @@ -9,6 +9,7 @@ import com.microsoft.bot.builder.UserState; import com.microsoft.bot.builder.inspection.InspectionState; import com.microsoft.bot.connector.ExecutorFactory; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; import com.microsoft.bot.connector.authentication.ChannelProvider; import com.microsoft.bot.connector.authentication.CredentialProvider; import com.microsoft.bot.integration.BotFrameworkHttpAdapter; @@ -64,6 +65,20 @@ public Configuration getConfiguration() { return new ClasspathPropertiesConfiguration(); } + + /** + * Returns the AuthenticationConfiguration for the application. + * + * By default, it uses the {@link AuthenticationConfiguration} class. + * Default scope of Singleton. + * @param configuration The Configuration object to read from. + * @return An AuthenticationConfiguration object. + */ + @Bean + public AuthenticationConfiguration getAuthenticationConfiguration(Configuration configuration) { + return new AuthenticationConfiguration(); + } + /** * Returns the CredentialProvider for the application. * diff --git a/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/ChannelServiceController.java b/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/ChannelServiceController.java new file mode 100644 index 000000000..4ee1fdb1c --- /dev/null +++ b/libraries/bot-integration-spring/src/main/java/com/microsoft/bot/integration/spring/ChannelServiceController.java @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.integration.spring; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.connector.authentication.AuthenticationException; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.AttachmentData; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationParameters; +import com.microsoft.bot.schema.ConversationResourceResponse; +import com.microsoft.bot.schema.ConversationsResult; +import com.microsoft.bot.schema.PagedMembersResult; +import com.microsoft.bot.schema.ResourceResponse; +import com.microsoft.bot.schema.Transcript; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * A super.class for a skill controller. + */ +// Note: this class instanceof marked as abstract to prevent the ASP runtime from registering it as a controller. +public abstract class ChannelServiceController { + + /** + * The slf4j Logger to use. Note that slf4j is configured by providing Log4j + * dependencies in the POM, and corresponding Log4j configuration in the + * 'resources' folder. + */ + private Logger logger = LoggerFactory.getLogger(BotController.class); + + private final ChannelServiceHandler handler; + + /** + * Initializes a new instance of the {@link ChannelServiceController} + * class. + * + * @param handler A {@link ChannelServiceHandler} that will handle + * the incoming request. + */ + protected ChannelServiceController(ChannelServiceHandler handler) { + this.handler = handler; + } + + /** + * SendToConversation. + * + * @param conversationId Conversation Id. + * @param activity Activity to send. + * @param authHeader Authentication header. + * + * @return A ResourceResponse. + */ + @PostMapping("v3/conversations/{conversationId}/activities") + public CompletableFuture> sendToConversation( + @PathVariable String conversationId, + @RequestBody Activity activity, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + + return handler.handleSendToConversation(authHeader, conversationId, activity) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * ReplyToActivity. + * + * @param conversationId Conversation Id. + * @param activityId activityId the reply is to (OPTONAL). + * @param activity Activity to send. + * @param authHeader Authentication header. + * + * @return A ResourceResponse. + */ + @PostMapping("v3/conversations/{conversationId}/activities/{activityId}") + public CompletableFuture> replyToActivity( + @PathVariable String conversationId, + @PathVariable String activityId, + @RequestBody Activity activity, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleReplyToActivity(authHeader, conversationId, activityId, activity) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * UpdateActivity. + * + * @param conversationId Conversation Id. + * @param activityId activityId to update. + * @param activity replacement Activity. + * @param authHeader Authentication header. + * + * @return A ResourceResponse. + */ + @PutMapping("v3/conversations/{conversationId}/activities/{activityId}") + public CompletableFuture> updateActivity( + @PathVariable String conversationId, + @PathVariable String activityId, + @RequestBody Activity activity, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleUpdateActivity(authHeader, conversationId, activityId, activity) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * DeleteActivity. + * + * @param conversationId Conversation Id. + * @param activityId activityId to delete. + * @param authHeader Authentication header. + * + * @return A void result if successful. + */ + @DeleteMapping("v3/conversations/{conversationId}/activities/{activityId}") + public CompletableFuture> deleteActivity( + @PathVariable String conversationId, + @PathVariable String activityId, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleDeleteActivity(authHeader, conversationId, activityId) + .handle((result, exception) -> { + if (exception == null) { + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * GetActivityMembers. + * + * Markdown=Content\Methods\GetActivityMembers.getmd(). + * + * @param conversationId Conversation Id. + * @param activityId Activity Id. + * @param authHeader Authentication header. + * + * @return A list of ChannelAccount. + */ + @GetMapping("v3/conversations/{conversationId}/activities/{activityId}/members") + public CompletableFuture>> getActivityMembers( + @PathVariable String conversationId, + @PathVariable String activityId, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleGetActivityMembers(authHeader, conversationId, activityId) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity>( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * CreateConversation. + * + * @param parameters Parameters to create the conversation from. + * @param authHeader Authentication header. + * + * @return A ConversationResourceResponse. + */ + @PostMapping("v3/conversations") + public CompletableFuture> createConversation( + @RequestBody ConversationParameters parameters, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleCreateConversation(authHeader, parameters) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * GetConversations. + * + * @param conversationId the conversation id to get conversations for. + * @param continuationToken skip or continuation token. + * @param authHeader Authentication header. + * + * @return A ConversationsResult. + */ + @GetMapping("v3/conversations") + public CompletableFuture> getConversations( + @RequestParam String conversationId, + @RequestParam String continuationToken, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleGetConversations(authHeader, conversationId, continuationToken) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * GetConversationMembers. + * + * @param conversationId Conversation Id. + * @param authHeader Authentication header. + * + * @return A List of ChannelAccount. + */ + @GetMapping("v3/conversations/{conversationId}/members") + public CompletableFuture>> getConversationMembers( + @PathVariable String conversationId, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleGetConversationMembers(authHeader, conversationId) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity>( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * GetConversationPagedMembers. + * + * @param conversationId Conversation Id. + * @param pageSize Suggested page size. + * @param continuationToken Continuation Token. + * @param authHeader Authentication header. + * + * @return A PagedMembersResult. + */ + @GetMapping("v3/conversations/{conversationId}/pagedmembers") + public CompletableFuture> getConversationPagedMembers( + @PathVariable String conversationId, + @RequestParam(name = "pageSize", defaultValue = "-1") int pageSize, + @RequestParam(name = "continuationToken") String continuationToken, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleGetConversationPagedMembers(authHeader, conversationId, pageSize, continuationToken) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * DeleteConversationMember. + * + * @param conversationId Conversation Id. + * @param memberId D of the member to delete from this + * conversation. + * @param authHeader Authentication header. + * + * @return A void result. + */ + @DeleteMapping("v3/conversations/{conversationId}/members/{memberId}") + public CompletableFuture> deleteConversationMember( + @PathVariable String conversationId, + @PathVariable String memberId, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleDeleteConversationMember(authHeader, conversationId, memberId) + .handle((result, exception) -> { + if (exception == null) { + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * SendConversationHistory. + * + * @param conversationId Conversation Id. + * @param history Historic activities. + * @param authHeader Authentication header. + * + * @return A ResourceResponse. + */ + @PostMapping("v3/conversations/{conversationId}/activities/history") + public CompletableFuture> sendConversationHistory( + @PathVariable String conversationId, + @RequestBody Transcript history, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleSendConversationHistory(authHeader, conversationId, history) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } + + /** + * UploadAttachment. + * + * @param conversationId Conversation Id. + * @param attachmentUpload Attachment data. + * @param authHeader Authentication header. + * + * @return A ResourceResponse. + */ + @PostMapping("v3/conversations/{conversationId}/attachments") + public CompletableFuture> uploadAttachment( + @PathVariable String conversationId, + @RequestBody AttachmentData attachmentUpload, + @RequestHeader(value = "Authorization", defaultValue = "") String authHeader + ) { + return handler.handleUploadAttachment(authHeader, conversationId, attachmentUpload) + .handle((result, exception) -> { + if (exception == null) { + if (result != null) { + return new ResponseEntity( + result, + HttpStatus.OK + ); + } + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } + + logger.error("Exception handling message", exception); + + if (exception instanceof CompletionException) { + if (exception.getCause() instanceof AuthenticationException) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + }); + } +} diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/EndOfConversationCodes.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/EndOfConversationCodes.java index 2b4d3bb81..43162b2d7 100644 --- a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/EndOfConversationCodes.java +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/EndOfConversationCodes.java @@ -38,7 +38,17 @@ public enum EndOfConversationCodes { /** * Enum value channelFailed. */ - CHANNEL_FAILED("channelFailed"); + CHANNEL_FAILED("channelFailed"), + + /** + * Enum value skillError. + */ + SKILL_ERROR("skillError"), + + /** + * Enum value channelFailed. + */ + ROOT_SKILL_ERROR("rootSkillError"); /** * The actual serialized value for a EndOfConversationCodes instance. @@ -47,7 +57,7 @@ public enum EndOfConversationCodes { /** * Creates a ActionTypes enum from a string. - * + * * @param withValue The string value. Should be a valid enum value. * @throws IllegalArgumentException If the string doesn't match a valid value. */ diff --git a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/RoleTypes.java b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/RoleTypes.java index e5683adba..0e38b5b29 100644 --- a/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/RoleTypes.java +++ b/libraries/bot-schema/src/main/java/com/microsoft/bot/schema/RoleTypes.java @@ -18,7 +18,12 @@ public enum RoleTypes { /** * Enum value bot. */ - BOT("bot"); + BOT("bot"), + + /** + * Enum value skill. + */ + SKILL("skill"); /** * The actual serialized value for a RoleTypes instance. @@ -27,7 +32,7 @@ public enum RoleTypes { /** * Creates a ActionTypes enum from a string. - * + * * @param withValue The string value. Should be a valid enum value. * @throws IllegalArgumentException If the string doesn't match a valid value. */ diff --git a/samples/17.multilingual-bot/src/main/java/com/microsoft/bot/sample/multilingual/translation/TranslationMiddleware.java b/samples/17.multilingual-bot/src/main/java/com/microsoft/bot/sample/multilingual/translation/TranslationMiddleware.java index d8455fcad..aa1ff6b80 100644 --- a/samples/17.multilingual-bot/src/main/java/com/microsoft/bot/sample/multilingual/translation/TranslationMiddleware.java +++ b/samples/17.multilingual-bot/src/main/java/com/microsoft/bot/sample/multilingual/translation/TranslationMiddleware.java @@ -74,7 +74,7 @@ public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next // Translate messages sent to the user to user language if (shouldTranslate) { ArrayList> tasks = new ArrayList>(); - for (Activity activity : activities.stream().filter(a -> a.getType() == ActivityTypes.MESSAGE).collect(Collectors.toList())) { + for (Activity activity : activities.stream().filter(a -> a.getType().equals(ActivityTypes.MESSAGE)).collect(Collectors.toList())) { tasks.add(this.translateMessageActivity(activity, userLanguage)); } @@ -92,7 +92,7 @@ public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next Boolean shouldTranslate = !userLanguage.equals(TranslationSettings.DEFAULT_LANGUAGE); // Translate messages sent to the user to user language - if (activity.getType() == ActivityTypes.MESSAGE) { + if (activity.getType().equals(ActivityTypes.MESSAGE)) { if (shouldTranslate) { this.translateMessageActivity(activity, userLanguage); } @@ -107,7 +107,7 @@ public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next } private CompletableFuture translateMessageActivity(Activity activity, String targetLocale) { - if (activity.getType() == ActivityTypes.MESSAGE) { + if (activity.getType().equals(ActivityTypes.MESSAGE)) { return this.translator.translate(activity.getText(), targetLocale).thenAccept(text -> { activity.setText(text); }); diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/LICENSE b/samples/80.skills-simple-bot-to-bot/DialogRootBot/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/README.md b/samples/80.skills-simple-bot-to-bot/DialogRootBot/README.md new file mode 100644 index 000000000..66752bfb8 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/README.md @@ -0,0 +1,3 @@ +# SimpleRootBot + +See [80.skills-simple-bot-to-bot](../Readme.md) for details on how to configure and run this sample. diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-new-rg.json b/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..ec2460d3a --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-preexisting-rg.json b/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..024dcf08d --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings. Defaults to \"\"." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/pom.xml b/samples/80.skills-simple-bot-to-bot/DialogRootBot/pom.xml new file mode 100644 index 000000000..5bc83ef58 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/pom.xml @@ -0,0 +1,238 @@ + + + + 4.0.0 + + com.microsoft.bot.sample + simpleRootBot + sample + jar + + ${project.groupId}:${project.artifactId} + This package contains a Java Simple Root Bot sample using Spring Boot. + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 2.4.0 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + Bot Framework Development + + Microsoft + https://dev.botframework.com/ + + + + + 1.8 + 1.8 + 1.8 + com.microsoft.bot.sample.simplerootbot.Application + + + + + junit + junit + 4.13.1 + test + + + org.springframework.boot + spring-boot-starter-test + 2.4.0 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-api + 2.11.0 + + + org.apache.logging.log4j + log4j-core + 2.13.2 + + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview9 + compile + + + + + + build + + true + + + + + src/main/resources + false + + + + + maven-compiler-plugin + 3.8.1 + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + com.microsoft.bot.sample.simplerootbot.Application + + + + + + com.microsoft.azure + azure-webapp-maven-plugin + 1.12.0 + + V2 + ${groupname} + ${botname} + + + JAVA_OPTS + -Dserver.port=80 + + + + linux + Java 8 + Java SE + + + + + ${project.basedir}/target + + *.jar + + + + + + + + + + + + publish + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + false + + + + attach-javadocs + + jar + + + + + + + + + diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/Application.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/Application.java new file mode 100644 index 000000000..55cb47636 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/Application.java @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.simplerootbot; + +import com.microsoft.bot.builder.Bot; +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MemoryStorage; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillHandler; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.integration.spring.BotController; +import com.microsoft.bot.integration.spring.BotDependencyConfiguration; +import com.microsoft.bot.sample.simplerootbot.authentication.AllowedSkillsClaimsValidator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +// +// This is the starting point of the Sprint Boot Bot application. +// +@SpringBootApplication + +// Use the default BotController to receive incoming Channel messages. A custom +// controller could be used by eliminating this import and creating a new +// org.springframework.web.bind.annotation.RestController. +// The default controller is created by the Spring Boot container using +// dependency injection. The default route is /api/messages. +@Import({BotController.class}) + +/** + * This class extends the BotDependencyConfiguration which provides the default + * implementations for a Bot application. The Application class should + * override methods in order to provide custom implementations. + */ +public class Application extends BotDependencyConfiguration { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /** + * Returns the Bot for this application. + * + *

+ * The @Component annotation could be used on the Bot class instead of this method + * with the @Bean annotation. + *

+ * + * @return The Bot implementation for this application. + */ + @Bean + public Bot getBot( + ConversationState conversationState, + SkillsConfiguration skillsConfig, + SkillHttpClient skillClient, + Configuration configuration + ) { + return new RootBot(conversationState, skillsConfig, skillClient, configuration); + } + + @Override + public AuthenticationConfiguration getAuthenticationConfiguration(Configuration configuration) { + AuthenticationConfiguration authenticationConfiguration = new AuthenticationConfiguration(); + authenticationConfiguration.setClaimsValidator( + new AllowedSkillsClaimsValidator(getSkillsConfiguration(configuration))); + return authenticationConfiguration; + } + + /** + * Returns a custom Adapter that provides error handling. + * + * @param configuration The Configuration object to use. + * @return An error handling BotFrameworkHttpAdapter. + */ + @Override + public BotFrameworkHttpAdapter getBotFrameworkHttpAdaptor(Configuration configuration) { + return new SkillAdapterWithErrorHandler( + configuration, + getConversationState(new MemoryStorage()), + getSkillHttpClient( + getCredentialProvider(configuration), + getSkillConversationIdFactoryBase(), + getChannelProvider(configuration)), + getSkillsConfiguration(configuration)); + } + + @Bean + public SkillsConfiguration getSkillsConfiguration(Configuration configuration) { + return new SkillsConfiguration(configuration); + } + + @Bean + public SkillHttpClient getSkillHttpClient( + CredentialProvider credentialProvider, + SkillConversationIdFactoryBase conversationIdFactory, + ChannelProvider channelProvider + ) { + return new SkillHttpClient(credentialProvider, conversationIdFactory, channelProvider); + } + + @Bean + public SkillConversationIdFactoryBase getSkillConversationIdFactoryBase() { + return new SkillConversationIdFactory(); + } + + @Bean public ChannelServiceHandler getChannelServiceHandler( + BotAdapter botAdapter, + Bot bot, + SkillConversationIdFactoryBase conversationIdFactory, + CredentialProvider credentialProvider, + AuthenticationConfiguration authConfig, + ChannelProvider channelProvider + ) { + return new SkillHandler( + botAdapter, + bot, + conversationIdFactory, + credentialProvider, + authConfig, + channelProvider); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/RootBot.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/RootBot.java new file mode 100644 index 000000000..d85a3dddc --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/RootBot.java @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.simplerootbot; + +import com.codepoetics.protonpack.collectors.CompletableFutures; +import com.microsoft.bot.builder.ActivityHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.TypedInvokeResponse; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.ChannelAccount; + +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * This class implements the functionality of the Bot. + * + *

+ * This is where application specific logic for interacting with the users would be added. For this + * sample, the {@link #onMessageActivity(TurnContext)} echos the text back to the user. The {@link + * #onMembersAdded(List, TurnContext)} will send a greeting to new conversation participants. + *

+ */ +public class RootBot extends ActivityHandler { + + public static final String ActiveSkillPropertyName = "com.microsoft.bot.sample.simplerootbot.ActiveSkillProperty"; + private StatePropertyAccessor activeSkillProperty; + private String botId; + private ConversationState conversationState; + private SkillHttpClient skillClient; + private SkillsConfiguration skillsConfig; + private BotFrameworkSkill targetSkill; + + public RootBot( + ConversationState conversationState, + SkillsConfiguration skillsConfig, + SkillHttpClient skillClient, + Configuration configuration + ) { + if (conversationState == null) { + throw new IllegalArgumentException("conversationState cannot be null."); + } + + if (skillsConfig == null) { + throw new IllegalArgumentException("skillsConfig cannot be null."); + } + + if (skillClient == null) { + throw new IllegalArgumentException("skillsConfig cannot be null."); + } + + if (configuration == null) { + throw new IllegalArgumentException("configuration cannot be null."); + } + + + this.conversationState = conversationState; + this.skillsConfig = skillsConfig; + this.skillClient = skillClient; + + botId = configuration.getProperty(MicrosoftAppCredentials.MICROSOFTAPPID); + + if (StringUtils.isEmpty(botId)) { + throw new IllegalArgumentException(String.format("%s instanceof not set in configuration", + MicrosoftAppCredentials.MICROSOFTAPPID)); + } + + // We use a single skill in this example. + String targetSkillId = "EchoSkillBot"; + if (!skillsConfig.getSkills().containsKey(targetSkillId)) { + throw new IllegalArgumentException( + String.format("Skill with D \"%s\" not found in configuration", targetSkillId) + ); + } else { + targetSkill = (BotFrameworkSkill) skillsConfig.getSkills().get(targetSkillId); + } + + // Create state property to track the active skill + activeSkillProperty = conversationState.createProperty(ActiveSkillPropertyName); + } + + @Override + public CompletableFuture onTurn(TurnContext turnContext) { + // Forward all activities except EndOfConversation to the skill. + if (!turnContext.getActivity().getType().equals(ActivityTypes.END_OF_CONVERSATION)) { + // Try to get the active skill + BotFrameworkSkill activeSkill = activeSkillProperty.get(turnContext).join(); + if (activeSkill != null) { + // Send the activity to the skill + sendToSkill(turnContext, activeSkill).join(); + return CompletableFuture.completedFuture(null); + } + } + + super.onTurn(turnContext); + + // Save any state changes that might have occured during the turn. + return conversationState.saveChanges(turnContext, false); + } + + @Override + protected CompletableFuture onMessageActivity(TurnContext turnContext) { + if (turnContext.getActivity().getText().contains("skill")) { + return turnContext.sendActivity(MessageFactory.text("Got it, connecting you to the skill...")) + .thenCompose(result -> { + activeSkillProperty.set(turnContext, targetSkill); + // Send the activity to the skill + return sendToSkill(turnContext, targetSkill); + }); + } + + // just respond + return turnContext.sendActivity( + MessageFactory.text("Me no nothin'. Say \"skill\" and I'll patch you through")) + .thenCompose(result -> conversationState.saveChanges(turnContext, true)); + } + + @Override + protected CompletableFuture onEndOfConversationActivity(TurnContext turnContext) { + // forget skill invocation + return activeSkillProperty.delete(turnContext).thenAccept(result -> { + // Show status message, text and value returned by the skill + String eocActivityMessage = String.format("Received %s.\n\nCode: %s", + ActivityTypes.END_OF_CONVERSATION, + turnContext.getActivity().getCode()); + + if (!StringUtils.isEmpty(turnContext.getActivity().getText())) { + eocActivityMessage += String.format("\n\nText: %s", turnContext.getActivity().getText()); + } + + if (turnContext.getActivity() != null && turnContext.getActivity().getValue() != null) { + eocActivityMessage += String.format("\n\nValue: %s", turnContext.getActivity().getValue()); + } + + turnContext.sendActivity(MessageFactory.text(eocActivityMessage)).thenCompose(sendResult ->{ + // We are back at the root + return turnContext.sendActivity( + MessageFactory.text("Back in the root bot. Say \"skill\" and I'll patch you through")) + .thenCompose(secondSendResult-> conversationState.saveChanges(turnContext)); + }); + }); + } + + + @Override + protected CompletableFuture onMembersAdded(List membersAdded, TurnContext turnContext) { + return membersAdded.stream() + .filter( + member -> !StringUtils + .equals(member.getId(), turnContext.getActivity().getRecipient().getId()) + ).map(channel -> turnContext.sendActivity(MessageFactory.text("Hello and welcome!"))) + .collect(CompletableFutures.toFutureList()).thenApply(resourceResponses -> null); + } + + private CompletableFuture sendToSkill(TurnContext turnContext, BotFrameworkSkill targetSkill) { + // NOTE: Always SaveChanges() before calling a skill so that any activity generated by the skill + // will have access to current accurate state. + return conversationState.saveChanges(turnContext, true) + .thenAccept(result -> { + // route the activity to the skill + skillClient.postActivity(botId, + targetSkill, + skillsConfig.getSkillHostEndpoint(), + turnContext.getActivity(), + Object.class) + .thenApply(response -> { + // Check response status + if (!(response.getStatus() >= 200 && response.getStatus() <= 299)) { + throw new RuntimeException( + String.format( + "Error invoking the skill id: \"%s\" at \"%s\" (status instanceof %s). \r\n %s", + targetSkill.getId(), + targetSkill.getSkillEndpoint(), + response.getStatus(), + response.getBody())); + } + return CompletableFuture.completedFuture(null); + }); + }); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillAdapterWithErrorHandler.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillAdapterWithErrorHandler.java new file mode 100644 index 000000000..5e751193f --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillAdapterWithErrorHandler.java @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. +package com.microsoft.bot.sample.simplerootbot; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.OnTurnErrorHandler; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.EndOfConversationCodes; +import com.microsoft.bot.schema.InputHints; + +public class SkillAdapterWithErrorHandler extends BotFrameworkHttpAdapter { + + private ConversationState conversationState = null; + private SkillHttpClient skillHttpClient = null; + private SkillsConfiguration skillsConfiguration = null; + private Configuration configuration = null; + + public SkillAdapterWithErrorHandler( + Configuration configuration, + ConversationState conversationState, + SkillHttpClient skillHttpClient, + SkillsConfiguration skillsConfiguration + ) { + super(configuration); + this.configuration = configuration; + this.conversationState = conversationState; + this.skillHttpClient = skillHttpClient; + this.skillsConfiguration = skillsConfiguration; + setOnTurnError(new SkillAdapterErrorHandler()); + } + + private class SkillAdapterErrorHandler implements OnTurnErrorHandler { + + @Override + public CompletableFuture invoke(TurnContext turnContext, Throwable exception) { + return sendErrorMessage(turnContext, exception).thenAccept(result -> { + endSkillConversation(turnContext); + }).thenAccept(endResult -> { + clearConversationState(turnContext); + }); + } + + private CompletableFuture sendErrorMessage(TurnContext turnContext, Throwable exception) { + try { + // Send a message to the user. + String errorMessageText = "The bot encountered an error or bug."; + Activity errorMessage = + MessageFactory.text(errorMessageText, errorMessageText, InputHints.IGNORING_INPUT); + return turnContext.sendActivity(errorMessage).thenAccept(result -> { + String secondLineMessageText = "To continue to run this bot, please fix the bot source code."; + Activity secondErrorMessage = + MessageFactory.text(secondLineMessageText, secondLineMessageText, InputHints.EXPECTING_INPUT); + turnContext.sendActivity(secondErrorMessage) + .thenApply( + sendResult -> { + // Send a trace activity, which will be displayed in the Bot Framework Emulator. + // Note: we return the entire exception in the value property to help the + // developer; + // this should not be done in production. + return TurnContext.traceActivity( + turnContext, + String.format("OnTurnError Trace %s", exception.toString()) + ); + } + ); + }).thenApply(finalResult -> null); + + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + private CompletableFuture endSkillConversation(TurnContext turnContext) { + if (skillHttpClient == null || skillsConfiguration == null) { + return CompletableFuture.completedFuture(null); + } + + // Inform the active skill that the conversation instanceof ended so that it has + // a chance to clean up. + // Note: ActiveSkillPropertyName instanceof set by the RooBot while messages are + // being + StatePropertyAccessor skillAccessor = + conversationState.createProperty(RootBot.ActiveSkillPropertyName); + // forwarded to a Skill. + return skillAccessor.get(turnContext, () -> null).thenApply(activeSkill -> { + if (activeSkill != null) { + String botId = configuration.getProperty(MicrosoftAppCredentials.MICROSOFTAPPID); + + Activity endOfConversation = Activity.createEndOfConversationActivity(); + endOfConversation.setCode(EndOfConversationCodes.ROOT_SKILL_ERROR); + endOfConversation + .applyConversationReference(turnContext.getActivity().getConversationReference(), true); + + return conversationState.saveChanges(turnContext, true).thenCompose(saveResult -> { + return skillHttpClient.postActivity( + botId, + activeSkill, + skillsConfiguration.getSkillHostEndpoint(), + endOfConversation, + Object.class + ); + + }); + } + return CompletableFuture.completedFuture(null); + }).thenApply(result -> null); + } + + private CompletableFuture clearConversationState(TurnContext turnContext) { + try { + return conversationState.delete(turnContext); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillConversationIdFactory.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillConversationIdFactory.java new file mode 100644 index 000000000..1aa0fd42a --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillConversationIdFactory.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.simplerootbot; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryOptions; +import com.microsoft.bot.builder.skills.SkillConversationReference; + +/** + * A {@link SkillConversationIdFactory} that uses an in memory + * {@link Map{TKey,TValue}} to store and retrieve {@link ConversationReference} + * instances. + */ +public class SkillConversationIdFactory extends SkillConversationIdFactoryBase { + + private final Map _conversationRefs = + new HashMap(); + + @Override + public CompletableFuture createSkillConversationId(SkillConversationIdFactoryOptions options) { + SkillConversationReference skillConversationReference = new SkillConversationReference(); + skillConversationReference.setConversationReference(options.getActivity().getConversationReference()); + skillConversationReference.setOAuthScope(options.getFromBotOAuthScope()); + String key = String.format( + "%s-%s-%s-%s-skillconvo", + options.getFromBotId(), + options.getBotFrameworkSkill().getAppId(), + skillConversationReference.getConversationReference().getConversation().getId(), + skillConversationReference.getConversationReference().getChannelId() + ); + _conversationRefs.put(key, skillConversationReference); + return CompletableFuture.completedFuture(key); + } + + @Override + public CompletableFuture getSkillConversationReference(String skillConversationId) { + SkillConversationReference conversationReference = _conversationRefs.get(skillConversationId); + return CompletableFuture.completedFuture(conversationReference); + } + + @Override + public CompletableFuture deleteConversationReference(String skillConversationId) { + _conversationRefs.remove(skillConversationId); + return CompletableFuture.completedFuture(null); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillsConfiguration.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillsConfiguration.java new file mode 100644 index 000000000..b73e64762 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillsConfiguration.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.simplerootbot; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; + +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.integration.Configuration; + +import org.apache.commons.lang3.StringUtils; + +/** + * A helper class that loads Skills information from configuration. + */ +public class SkillsConfiguration { + + private URI skillHostEndpoint; + + private Map skills = new HashMap(); + + public SkillsConfiguration(Configuration configuration) { + + boolean noMoreEntries = false; + int indexCount = 0; + while (!noMoreEntries) { + String botID = configuration.getProperty(String.format("BotFrameworkSkills[%d].Id", indexCount)); + String botAppId = configuration.getProperty(String.format("BotFrameworkSkills[%d].AppId", indexCount)); + String skillEndPoint = + configuration.getProperty(String.format("BotFrameworkSkills[%d].SkillEndpoint", indexCount)); + if ( + StringUtils.isNotBlank(botID) && StringUtils.isNotBlank(botAppId) + && StringUtils.isNotBlank(skillEndPoint) + ) { + BotFrameworkSkill newSkill = new BotFrameworkSkill(); + newSkill.setId(botID); + newSkill.setAppId(botAppId); + try { + newSkill.setSkillEndpoint(new URI(skillEndPoint)); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + skills.put(botID, newSkill); + indexCount++; + } else { + noMoreEntries = true; + } + } + + String skillHost = configuration.getProperty("SkillhostEndpoint"); + if (!StringUtils.isEmpty(skillHost)) { + try { + skillHostEndpoint = new URI(skillHost); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + } + } + + /** + * @return the SkillHostEndpoint value as a Uri. + */ + public URI getSkillHostEndpoint() { + return this.skillHostEndpoint; + } + + /** + * @return the Skills value as a Dictionary. + */ + public Map getSkills() { + return this.skills; + } + +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/authentication/AllowedSkillsClaimsValidator.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/authentication/AllowedSkillsClaimsValidator.java new file mode 100644 index 000000000..7f12718c6 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/authentication/AllowedSkillsClaimsValidator.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.simplerootbot.authentication; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.ClaimsValidator; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.sample.simplerootbot.SkillsConfiguration; + +/** + * Sample claims validator that loads an allowed list from configuration if + * presentand checks that requests are coming from allowed parent bots. + */ +public class AllowedSkillsClaimsValidator extends ClaimsValidator { + + private final List allowedSkills; + + public AllowedSkillsClaimsValidator(SkillsConfiguration skillsConfig) { + if (skillsConfig == null) { + throw new IllegalArgumentException("config cannot be null."); + } + + // Load the appIds for the configured skills (we will only allow responses from skills we have configured). + allowedSkills = new ArrayList(); + for (Map.Entry configuration : skillsConfig.getSkills().entrySet()) { + allowedSkills.add(configuration.getValue().getAppId()); + } + } + + @Override + public CompletableFuture validateClaims(Map claims) { + // If _allowedCallers contains an "*", we allow all callers. + if (SkillValidation.isSkillClaim(claims)) { + // Check that the appId claim in the skill request instanceof in the list of callers + // configured for this bot. + String appId = JwtTokenValidation.getAppIdFromClaims(claims); + if (!allowedSkills.contains(appId)) { + return Async.completeExceptionally( + new RuntimeException( + String.format("Received a request from an application with an appID of \"%s\". " + + "To enable requests from this skill, add the skill to your configuration file.", appId) + ) + ); + } + } + + return CompletableFuture.completedFuture(null); + } +} + diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/controller/SkillController.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/controller/SkillController.java new file mode 100644 index 000000000..4b3e4e4bd --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/controller/SkillController.java @@ -0,0 +1,16 @@ +package com.microsoft.bot.sample.simplerootbot.controller; + +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.integration.spring.ChannelServiceController; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = {"/api/skills"}) +public class SkillController extends ChannelServiceController { + + public SkillController(ChannelServiceHandler handler) { + super(handler); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/application.properties b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/application.properties new file mode 100644 index 000000000..611908690 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/application.properties @@ -0,0 +1,8 @@ +MicrosoftAppId= +MicrosoftAppPassword= +server.port=3978 +SkillhostEndpoint=http://localhost:3978/api/skills/ +#replicate these three entries, incrementing the index value [0] for each successive Skill that is added. +BotFrameworkSkills[0].Id=EchoSkillBot +BotFrameworkSkills[0].AppId= "Add the App ID for the skill here" +BotFrameworkSkills[0].SkillEndpoint=http://localhost:39783/api/messages diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/log4j2.json b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/log4j2.json new file mode 100644 index 000000000..67c0ad530 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/resources/log4j2.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "name": "Default", + "appenders": { + "Console": { + "name": "Console-Appender", + "target": "SYSTEM_OUT", + "PatternLayout": {"pattern": "[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n"} + } + }, + "loggers": { + "root": { + "level": "debug", + "appender-ref": {"ref": "Console-Appender","level": "debug"} + } + } + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/META-INF/MANIFEST.MF b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..254272e1c --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: + diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/WEB-INF/web.xml b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..383c19004 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + dispatcher + + org.springframework.web.servlet.DispatcherServlet + + + contextConfigLocation + /WEB-INF/spring/dispatcher-config.xml + + 1 + \ No newline at end of file diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/index.html b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/index.html new file mode 100644 index 000000000..d5ba5158e --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/main/webapp/index.html @@ -0,0 +1,418 @@ + + + + + + + EchoBot + + + + + +
+
+
+
Spring Boot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + diff --git a/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/test/java/com/microsoft/bot/sample/simplerootbot/ApplicationTest.java b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/test/java/com/microsoft/bot/sample/simplerootbot/ApplicationTest.java new file mode 100644 index 000000000..a8cead7bf --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogRootBot/src/test/java/com/microsoft/bot/sample/simplerootbot/ApplicationTest.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.simplerootbot; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } + +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/LICENSE b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/README.md b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/README.md new file mode 100644 index 000000000..b2c9ea098 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/README.md @@ -0,0 +1,3 @@ +# EchoSkillBot + +See [80.skills-simple-bot-to-bot](../Readme.md) for details on how to configure and run this sample. diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-new-rg.json b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..ec2460d3a --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-preexisting-rg.json b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..024dcf08d --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings. Defaults to \"\"." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/pom.xml b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/pom.xml new file mode 100644 index 000000000..16077c086 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/pom.xml @@ -0,0 +1,238 @@ + + + + 4.0.0 + + com.microsoft.bot.sample + echoSkillbot + sample + jar + + ${project.groupId}:${project.artifactId} + This package contains a Java Echo Skill Bot sample using Spring Boot. + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 2.4.0 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + Bot Framework Development + + Microsoft + https://dev.botframework.com/ + + + + + 1.8 + 1.8 + 1.8 + com.microsoft.bot.sample.echoskillbot.Application + + + + + junit + junit + 4.13.1 + test + + + org.springframework.boot + spring-boot-starter-test + 2.4.0 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-api + 2.11.0 + + + org.apache.logging.log4j + log4j-core + 2.13.2 + + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview9 + compile + + + + + + build + + true + + + + + src/main/resources + false + + + + + maven-compiler-plugin + 3.8.1 + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + com.microsoft.bot.sample.echoskillbot.Application + + + + + + com.microsoft.azure + azure-webapp-maven-plugin + 1.12.0 + + V2 + ${groupname} + ${botname} + + + JAVA_OPTS + -Dserver.port=80 + + + + linux + Java 8 + Java SE + + + + + ${project.basedir}/target + + *.jar + + + + + + + + + + + + publish + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + false + + + + attach-javadocs + + jar + + + + + + + + + diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/Application.java b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/Application.java new file mode 100644 index 000000000..52995f61c --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/Application.java @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.echoskillbot; + +import com.microsoft.bot.builder.Bot; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.spring.BotController; +import com.microsoft.bot.integration.spring.BotDependencyConfiguration; +import com.microsoft.bot.sample.echoskillbot.authentication.AllowedCallersClaimsValidator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +// +// This is the starting point of the Sprint Boot Bot application. +// +@SpringBootApplication + +// Use the default BotController to receive incoming Channel messages. A custom +// controller could be used by eliminating this import and creating a new +// org.springframework.web.bind.annotation.RestController. +// The default controller is created by the Spring Boot container using +// dependency injection. The default route is /api/messages. +@Import({BotController.class}) + +/** + * This class extends the BotDependencyConfiguration which provides the default + * implementations for a Bot application. The Application class should override + * methods in order to provide custom implementations. + */ +public class Application extends BotDependencyConfiguration { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /** + * Returns the Bot for this application. + * + *

+ * The @Component annotation could be used on the Bot class instead of this + * method with the @Bean annotation. + *

+ * + * @return The Bot implementation for this application. + */ + @Bean + public Bot getBot() { + return new EchoBot(); + } + + @Override + public AuthenticationConfiguration getAuthenticationConfiguration(Configuration configuration) { + AuthenticationConfiguration authenticationConfiguration = new AuthenticationConfiguration(); + authenticationConfiguration.setClaimsValidator(new AllowedCallersClaimsValidator(configuration)); + return authenticationConfiguration; + } + + /** + * Returns a custom Adapter that provides error handling. + * + * @param configuration The Configuration object to use. + * @return An error handling BotFrameworkHttpAdapter. + */ + @Override + public BotFrameworkHttpAdapter getBotFrameworkHttpAdaptor(Configuration configuration) { + return new SkillAdapterWithErrorHandler(configuration, getAuthenticationConfiguration(configuration)); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/EchoBot.java b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/EchoBot.java new file mode 100644 index 000000000..e3204236a --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/EchoBot.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.echoskillbot; + +import com.microsoft.bot.builder.ActivityHandler; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.EndOfConversationCodes; +import com.microsoft.bot.schema.InputHints; + +import java.util.concurrent.CompletableFuture; + +/** + * This class implements the functionality of the Bot. + * + *

+ * This is where application specific logic for interacting with the users would + * be added. For this sample, the {@link #onMessageActivity(TurnContext)} echos + * the text back to the user. The {@link #onMembersAdded(List, TurnContext)} + * will send a greeting to new conversation participants. + *

+ */ +public class EchoBot extends ActivityHandler { + + @Override + protected CompletableFuture onMessageActivity(TurnContext turnContext) { + if ( + turnContext.getActivity().getText().contains("end") || turnContext.getActivity().getText().contains("stop") + ) { + String messageText = "ending conversation from the skill..."; + return turnContext.sendActivity(MessageFactory.text(messageText, messageText, InputHints.IGNORING_INPUT)) + .thenApply(result -> { + Activity endOfConversation = Activity.createEndOfConversationActivity(); + endOfConversation.setCode(EndOfConversationCodes.COMPLETED_SUCCESSFULLY); + return turnContext.sendActivity(endOfConversation); + }) + .thenApply(finalResult -> null); + } else { + String messageText = String.format("Echo: %s", turnContext.getActivity().getText()); + return turnContext.sendActivity(MessageFactory.text(messageText, messageText, InputHints.IGNORING_INPUT)) + .thenApply(result -> { + String nextMessageText = + "Say \"end\" or \"stop\" and I'll end the conversation and back to the parent."; + return turnContext.sendActivity( + MessageFactory.text(nextMessageText, nextMessageText, InputHints.EXPECTING_INPUT) + ); + }) + .thenApply(result -> null); + } + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/SkillAdapterWithErrorHandler.java b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/SkillAdapterWithErrorHandler.java new file mode 100644 index 000000000..a392cc52b --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/SkillAdapterWithErrorHandler.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. +package com.microsoft.bot.sample.echoskillbot; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.OnTurnErrorHandler; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.EndOfConversationCodes; +import com.microsoft.bot.schema.InputHints; + +public class SkillAdapterWithErrorHandler extends BotFrameworkHttpAdapter { + + public SkillAdapterWithErrorHandler( + Configuration configuration, + AuthenticationConfiguration authenticationConfiguration + ) { + super(configuration, authenticationConfiguration); + setOnTurnError(new SkillAdapterErrorHandler()); + } + + private class SkillAdapterErrorHandler implements OnTurnErrorHandler { + + @Override + public CompletableFuture invoke(TurnContext turnContext, Throwable exception) { + return sendErrorMessage(turnContext, exception).thenAccept(result -> { + sendEoCToParent(turnContext, exception); + }); + } + + private CompletableFuture sendErrorMessage(TurnContext turnContext, Throwable exception) { + try { + // Send a message to the user. + String errorMessageText = "The skill encountered an error or bug."; + Activity errorMessage = + MessageFactory.text(errorMessageText, errorMessageText, InputHints.IGNORING_INPUT); + return turnContext.sendActivity(errorMessage).thenAccept(result -> { + String secondLineMessageText = "To continue to run this bot, please fix the bot source code."; + Activity secondErrorMessage = + MessageFactory.text(secondLineMessageText, secondLineMessageText, InputHints.EXPECTING_INPUT); + turnContext.sendActivity(secondErrorMessage) + .thenApply( + sendResult -> { + // Send a trace activity, which will be displayed in the Bot Framework Emulator. + // Note: we return the entire exception in the value property to help the + // developer; + // this should not be done in production. + return TurnContext.traceActivity( + turnContext, + String.format("OnTurnError Trace %s", exception.toString()) + ); + + } + ); + }); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + private CompletableFuture sendEoCToParent(TurnContext turnContext, Throwable exception) { + try { + // Send an EndOfConversation activity to the skill caller with the error to end + // the conversation, + // and let the caller decide what to do. + Activity endOfConversation = Activity.createEndOfConversationActivity(); + endOfConversation.setCode(EndOfConversationCodes.SKILL_ERROR); + endOfConversation.setText(exception.getMessage()); + return turnContext.sendActivity(endOfConversation).thenApply(result -> null); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/authentication/AllowedCallersClaimsValidator.java b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/authentication/AllowedCallersClaimsValidator.java new file mode 100644 index 000000000..e85d547c7 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/java/com/microsoft/bot/sample/echoskillbot/authentication/AllowedCallersClaimsValidator.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.echoskillbot.authentication; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.ClaimsValidator; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.integration.Configuration; + +/** + * Sample claims validator that loads an allowed list from configuration if + * presentand checks that requests are coming from allowed parent bots. + */ +public class AllowedCallersClaimsValidator extends ClaimsValidator { + + private final String configKey = "AllowedCallers"; + private final List allowedCallers; + + public AllowedCallersClaimsValidator(Configuration config) { + if (config == null) { + throw new IllegalArgumentException("config cannot be null."); + } + + // AllowedCallers instanceof the setting in the application.properties file + // that consists of the list of parent bot Ds that are allowed to access the + // skill. + // To add a new parent bot, simply edit the AllowedCallers and add + // the parent bot's Microsoft app ID to the list. + // In this sample, we allow all callers if AllowedCallers contains an "*". + String[] appsList = config.getProperties(configKey); + if (appsList == null) { + throw new IllegalStateException(String.format("\"%s\" not found in configuration.", configKey)); + } + + allowedCallers = Arrays.asList(appsList); + } + + @Override + public CompletableFuture validateClaims(Map claims) { + // If _allowedCallers contains an "*", we allow all callers. + if (SkillValidation.isSkillClaim(claims) && !allowedCallers.contains("*")) { + // Check that the appId claim in the skill request instanceof in the list of + // callers configured for this bot. + String appId = JwtTokenValidation.getAppIdFromClaims(claims); + if (!allowedCallers.contains(appId)) { + return Async.completeExceptionally( + new RuntimeException( + String.format( + "Received a request from a bot with an app ID of \"%s\". " + + "To enable requests from this caller, add the app ID to your configuration file.", + appId + ) + ) + ); + } + } + + return CompletableFuture.completedFuture(null); + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/application.properties b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/application.properties new file mode 100644 index 000000000..54a3053bd --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/application.properties @@ -0,0 +1,9 @@ +MicrosoftAppId= +MicrosoftAppPassword= +server.port=39783 +# This is a comma separate list with the App IDs that will have access to the skill. +# This setting is used in AllowedCallersClaimsValidator. +# Examples: +# * allows all callers. +# AppId1,AppId2 only allows access to parent bots with "AppId1" and "AppId2". +AllowedCallers=* diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/log4j2.json b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/log4j2.json new file mode 100644 index 000000000..67c0ad530 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/resources/log4j2.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "name": "Default", + "appenders": { + "Console": { + "name": "Console-Appender", + "target": "SYSTEM_OUT", + "PatternLayout": {"pattern": "[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n"} + } + }, + "loggers": { + "root": { + "level": "debug", + "appender-ref": {"ref": "Console-Appender","level": "debug"} + } + } + } +} diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/META-INF/MANIFEST.MF b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..254272e1c --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: + diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/WEB-INF/web.xml b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..383c19004 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + dispatcher + + org.springframework.web.servlet.DispatcherServlet + + + contextConfigLocation + /WEB-INF/spring/dispatcher-config.xml + + 1 + \ No newline at end of file diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/index.html b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/index.html new file mode 100644 index 000000000..d5ba5158e --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/index.html @@ -0,0 +1,418 @@ + + + + + + + EchoBot + + + + + +
+
+
+
Spring Boot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json new file mode 100644 index 000000000..924b68e6f --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.0.0.json", + "$id": "EchoSkillBot", + "name": "Echo Skill bot", + "version": "1.0", + "description": "This is a sample echo skill", + "publisherName": "Microsoft", + "privacyUrl": "https://echoskillbot.contoso.com/privacy.html", + "copyright": "Copyright (c) Microsoft Corporation. All rights reserved.", + "license": "", + "iconUrl": "https://echoskillbot.contoso.com/icon.png", + "tags": [ + "sample", + "echo" + ], + "endpoints": [ + { + "name": "default", + "protocol": "BotFrameworkV3", + "description": "Default endpoint for the skill", + "endpointUrl": "http://echoskillbot.contoso.com/api/messages", + "msAppId": "00000000-0000-0000-0000-000000000000" + } + ] + } \ No newline at end of file diff --git a/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/test/java/com/microsoft/bot/sample/echoskillbot/ApplicationTest.java b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/test/java/com/microsoft/bot/sample/echoskillbot/ApplicationTest.java new file mode 100644 index 000000000..1a6fb0e7b --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/DialogSkillBot/src/test/java/com/microsoft/bot/sample/echoskillbot/ApplicationTest.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.echoskillbot; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } + +} diff --git a/samples/80.skills-simple-bot-to-bot/README.md b/samples/80.skills-simple-bot-to-bot/README.md new file mode 100644 index 000000000..b8de835a0 --- /dev/null +++ b/samples/80.skills-simple-bot-to-bot/README.md @@ -0,0 +1,59 @@ +# SimpleBotToBot Echo Skill + +Bot Framework v4 skills echo sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to create a simple skill consumer (SimpleRootBot) that sends message activities to a skill (EchoSkillBot) that echoes it back. + +## Prerequisites + +- Java 1.8+ +- Install [Maven](https://maven.apache.org/) +- An account on [Azure](https://azure.microsoft.com) if you want to deploy to Azure. + +## Key concepts in this sample + +The solution includes a parent bot (`SimpleRootBot`) and a skill bot (`EchoSkillBot`) and shows how the parent bot can post activities to the skill bot and returns the skill responses to the user. + +- `SimpleRootBot`: this project shows how to consume an echo skill and includes: + - A [RootBot](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/RootBot.java) that calls the echo skill and keeps the conversation active until the user says "end" or "stop". [RootBot](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/RootBot.java) also keeps track of the conversation with the skill and handles the `EndOfConversation` activity received from the skill to terminate the conversation + - A simple [SkillConversationIdFactory](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillConversationIdFactory.java) based on an in memory `Map` that creates and maintains conversation IDs used to interact with a skill + - A [SkillsConfiguration](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/SkillsConfiguration.java) class that can load skill definitions from `appsettings` + - A [SkillController](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/controller/SkillController.java) that handles skill responses + - An [AllowedSkillsClaimsValidator](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/Authentication/AllowedSkillsClaimsValidator.java) class that is used to authenticate that responses sent to the bot are coming from the configured skills + - A [Application](SimpleRootBot/src/main/java/com/microsoft/bot/sample/simplerootbot/Application.java) class that shows how to register the different skill components for dependency injection +- `EchoSkillBot`: this project shows a simple echo skill that receives message activities from the parent bot and echoes what the user said. This project includes: + - A sample [EchoBot](EchoSkillBot/src/main/java/com/microsoft/echoskillbot/EchoBot.java) that shows how to send EndOfConversation based on the message sent to the skill and yield control back to the parent bot + - A sample [AllowedCallersClaimsValidator](EchoSkillBot/src/main/java/com/microsoft/echoskillbot/authentication/AllowedCallersClaimsValidator.java) that shows how validate that the skill is only invoked from a list of allowed callers + - A [sample skill manifest](EchoSkillBot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json) that describes what the skill can do + +## To try this sample + +- Clone the repository + + ```bash + git clone https://github.com/microsoft/botbuilder-samples.git + ``` + +- Create a bot registration in the azure portal for the `EchoSkillBot` and update [EchoSkillBot/application.properties](EchoSkillBot/src/main/resources/application.properties) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Create a bot registration in the azure portal for the `SimpleRootBot` and update [SimpleRootBot/application.properties](SimpleRootBot/src/main/resources/application.properties) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Update the `BotFrameworkSkills` section in [SimpleRootBot/application.properties](SimpleRootBot/src/main/resources/application.properties) with the app ID for the skill you created in the previous step +- (Optionally) Add the `SimpleRootBot` `MicrosoftAppId` to the `AllowedCallers` list in [EchoSkillBot/application.properties](EchoSkillBot/src/main/resources/application.properties) +- Open the `SimpleBotToBot` project and start it for debugging +- Open the `EchoSkillsBot` project and start it for debugging + + +## Testing the bot using Bot Framework Emulator + +[Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + +- Install the Bot Framework Emulator version 4.7.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to the bot using Bot Framework Emulator + +- Launch Bot Framework Emulator +- File -> Open Bot +- Enter a Bot URL of `http://localhost:3978/api/messages`, the `MicrosoftAppId` and `MicrosoftAppPassword` for the `SimpleRootBot` + +## Deploy the bots to Azure + +To learn more about deploying a bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete list of deployment instructions. diff --git a/samples/81.skills-skilldialog/README.md b/samples/81.skills-skilldialog/README.md new file mode 100644 index 000000000..c450052cb --- /dev/null +++ b/samples/81.skills-skilldialog/README.md @@ -0,0 +1,97 @@ +# SkillDialog + +Bot Framework v4 Skills with Dialogs sample. + +This bot has been created using the [Bot Framework](https://dev.botframework.com); it shows how to use a skill dialog from a root bot. + +## Prerequisites + +- Java 1.8+ +- Install [Maven](https://maven.apache.org/) +- An account on [Azure](https://azure.microsoft.com) if you want to deploy to Azure. + +## Key concepts in this sample + +The solution uses dialogs, within both a parent bot (`dialog-root-bot`) and a skill bot (`dialog-skill-bot`). +It demonstrates how to post activities from the parent bot to the skill bot and return the skill responses to the user. + +- `dialog-root-bot`: this project shows how to consume a skill bot using a `SkillDialog`. It includes: + - A [Main Dialog](dialog-root-bot/dialogs/main_dialog.py) that can call different actions on a skill using a `SkillDialog`: + - To send events activities. + - To send message activities. + - To cancel a `SkillDialog` using `CancelAllDialogsAsync` that automatically sends an `EndOfConversation` activity to remotely let a skill know that it needs to end a conversation. + - A sample [AdapterWithErrorHandler](dialog-root-bot/adapter_with_error_handler.py) adapter that shows how to handle errors, terminate skills and send traces back to the emulator to help debugging the bot. + - A sample [AllowedSkillsClaimsValidator](dialog-root-bot/authentication/allowed_skills_claims_validator.py) class that shows how to validate that responses sent to the bot are coming from the configured skills. + - A [Logger Middleware](dialog-root-bot/middleware/logger_middleware.py) that shows how to handle and log activities coming from a skill. + - A [SkillConversationIdFactory](dialog-root-bot/skill_conversation_id_factory.py) based on `Storage` used to create and maintain conversation IDs to interact with a skill. + - A [SkillConfiguration](dialog-root-bot/config.py) class that can load skill definitions from the `DefaultConfig` class. + - An [app.py](dialog-root-bot/app.py) class that shows how to register the different root bot components. This file also creates a `SkillHandler` and `aiohttp_channel_service_routes` which are used to handle responses sent from the skills. +- `dialog_skill_bot`: this project shows a modified CoreBot that acts as a skill. It receives event and message activities from the parent bot and executes the requested tasks. This project includes: + - An [ActivityRouterDialog](dialog-skill-bot/dialogs/activity_router_dialog.py) that handles Event and Message activities coming from a parent and performs different tasks. + - Event activities are routed to specific dialogs using the parameters provided in the `Values` property of the activity. + - Message activities are sent to LUIS if configured and trigger the desired tasks if the intent is recognized. + - A sample [ActivityHandler](dialog-skill-bot/bots/skill_bot.py) that uses the `run_dialog` method on `DialogExtensions`. + + Note: Starting in Bot Framework 4.8, the `DialogExtensions` class was introduced to provide a `run_dialog` method wich adds support to automatically send `EndOfConversation` with return values when the bot is running as a skill and the current dialog ends. It also handles reprompt messages to resume a skill where it left of. + - A sample [SkillAdapterWithErrorHandler](dialog-skill-bot/skill_adapter_with_error_handler.py) adapter that shows how to handle errors, terminate the skills, send traces back to the emulator to help debugging the bot and send `EndOfConversation` messages to the parent bot with details of the error. + - A sample [AllowedCallersClaimsValidator](dialog-skill-bot/authentication/allow_callers_claims_validation.py) that shows how to validate that the skill is only invoked from a list of allowed callers + - An [app.py](dialog-skill-bot/app.py) class that shows how to register the different skill components. + - A [sample skill manifest](dialog-skill-bot/wwwroot/manifest/dialogchildbot-manifest-1.0.json) that describes what the skill can do. + + + +## To try this sample + +- Clone the repository + + ```bash + git clone https://github.com/microsoft/botbuilder-samples.git + ``` + +- Create a bot registration in the azure portal for the `dialog-skill-bot` and update [dialog-skill-bot/config.py](dialog-skill-bot/config.py) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Create a bot registration in the azure portal for the `dialog-root-bot` and update [dialog-root-bot/config.py](dialog-root-bot/config.py) with the `MicrosoftAppId` and `MicrosoftAppPassword` of the new bot registration +- Update the `SKILLS.app_id` in [dialog-root-bot/config.py](dialog-root-bot/config.py) with the `MicrosoftAppId` for the skill you created in the previous step +- (Optionally) Add the `dialog-root-bot` `MicrosoftAppId` to the `AllowedCallers` comma separated list in [dialog-skill-bot/config.py](dialog-skill-bot/config.py) + +## Running the sample + +- In a terminal, navigate to `samples\python\81.skills-skilldialog\dialog-skill-bot` + + ```bash + cd samples\python\81.skills-skilldialog\dialog-skill-bot + ``` + +- Activate your desired virtual environment + +- Run `pip install -r requirements.txt` to install all dependencies + +- Run your bot with `python app.py` + +- Open a **second** terminal window and navigate to `samples\python\81.skills-skilldialog\dialog-root-bot` + + ```bash + cd samples\python\81.skills-skilldialog\dialog-root-bot + ``` + +- Activate your desired virtual environment + +- Run `pip install -r requirements.txt` to install all dependencies + +- Run your bot with `python app.py` + + +## Testing the bot using Bot Framework Emulator + +[Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + +- Install the Bot Framework Emulator version 4.7.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to the bot using Bot Framework Emulator + +- Launch Bot Framework Emulator +- File -> Open Bot +- Enter a Bot URL of `http://localhost:3978/api/messages`, the `MicrosoftAppId` and `MicrosoftAppPassword` for the `dialog-root-bot` + +## Deploy the bots to Azure + +To learn more about deploying a bot to Azure, see [Deploy your bot to Azure](https://aka.ms/azuredeployment) for a complete list of deployment instructions. diff --git a/samples/81.skills-skilldialog/dialog-root-bot/.vscode/settings.json b/samples/81.skills-skilldialog/dialog-root-bot/.vscode/settings.json new file mode 100644 index 000000000..e0f15db2e --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/samples/81.skills-skilldialog/dialog-root-bot/LICENSE b/samples/81.skills-skilldialog/dialog-root-bot/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/samples/81.skills-skilldialog/dialog-root-bot/README.md b/samples/81.skills-skilldialog/dialog-root-bot/README.md new file mode 100644 index 000000000..275225592 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/README.md @@ -0,0 +1,3 @@ +# SimpleRootBot + +See [81.skills-skilldialog](../Readme.md) for details on how to configure and run this sample. diff --git a/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-new-rg.json b/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..ec2460d3a --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..024dcf08d --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings. Defaults to \"\"." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/pom.xml b/samples/81.skills-skilldialog/dialog-root-bot/pom.xml new file mode 100644 index 000000000..071b39b52 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/pom.xml @@ -0,0 +1,249 @@ + + + + 4.0.0 + + com.microsoft.bot.sample + dialogrootbot + sample + jar + + ${project.groupId}:${project.artifactId} + This package contains a Java Root Dialog Skill bot sample using Spring Boot. + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 2.4.0 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + Bot Framework Development + + Microsoft + https://dev.botframework.com/ + + + + + 1.8 + 1.8 + 1.8 + com.microsoft.bot.sample.dialogrootbot.Application + + + + + junit + junit + 4.13.1 + test + + + org.springframework.boot + spring-boot-starter-test + 2.4.0 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-api + 2.11.0 + + + org.apache.logging.log4j + log4j-core + 2.13.2 + + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview9 + compile + + + com.microsoft.bot + bot-dialogs + 4.6.0-preview9 + compile + + + com.microsoft.bot + bot-builder + 4.6.0-preview9 + compile + + + + + build + + true + + + + + src/main/resources + false + + + + + maven-compiler-plugin + 3.8.1 + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + com.microsoft.bot.sample.dialogrootbot.Application + + + + + + com.microsoft.azure + azure-webapp-maven-plugin + 1.12.0 + + V2 + ${groupname} + ${botname} + + + JAVA_OPTS + -Dserver.port=80 + + + + linux + Java 8 + Java SE + + + + + ${project.basedir}/target + + *.jar + + + + + + + + + + + + publish + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + false + + + + attach-javadocs + + jar + + + + + + + + + diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdapterWithErrorHandler.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdapterWithErrorHandler.java new file mode 100644 index 000000000..9846c6a9f --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdapterWithErrorHandler.java @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. +package com.microsoft.bot.sample.dialogrootbot; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.OnTurnErrorHandler; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.sample.dialogrootbot.dialogs.MainDialog; +import com.microsoft.bot.sample.dialogrootbot.middleware.ConsoleLogger; +import com.microsoft.bot.sample.dialogrootbot.middleware.LoggerMiddleware; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.EndOfConversationCodes; +import com.microsoft.bot.schema.InputHints; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AdapterWithErrorHandler extends BotFrameworkHttpAdapter { + + private ConversationState conversationState = null; + private SkillHttpClient skillHttpClient = null; + private SkillsConfiguration skillsConfiguration = null; + private Configuration configuration = null; + private Logger logger = LoggerFactory.getLogger(AdapterWithErrorHandler.class); + + public AdapterWithErrorHandler( + Configuration configuration, + ConversationState conversationState, + SkillHttpClient skillHttpClient, + SkillsConfiguration skillsConfiguration + ) { + super(configuration); + this.configuration = configuration; + this.conversationState = conversationState; + this.skillHttpClient = skillHttpClient; + this.skillsConfiguration = skillsConfiguration; + setOnTurnError(new AdapterErrorHandler()); + use(new LoggerMiddleware(new ConsoleLogger())); + } + + private class AdapterErrorHandler implements OnTurnErrorHandler { + + @Override + public CompletableFuture invoke(TurnContext turnContext, Throwable exception) { + return sendErrorMessage(turnContext, exception).thenAccept(result -> { + endSkillConversation(turnContext); + }).thenAccept(endResult -> { + clearConversationState(turnContext); + }); + } + + private CompletableFuture sendErrorMessage(TurnContext turnContext, Throwable exception) { + try { + // Send a message to the user. + String errorMessageText = "The bot encountered an error or bug."; + Activity errorMessage = + MessageFactory.text(errorMessageText, errorMessageText, InputHints.IGNORING_INPUT); + return turnContext.sendActivity(errorMessage).thenAccept(result -> { + String secondLineMessageText = "To continue to run this bot, please fix the bot source code."; + Activity secondErrorMessage = + MessageFactory.text(secondLineMessageText, secondLineMessageText, InputHints.EXPECTING_INPUT); + turnContext.sendActivity(secondErrorMessage) + .thenApply( + sendResult -> { + // Send a trace activity, which will be displayed in the Bot Framework Emulator. + // Note: we return the entire exception in the value property to help the + // developer; + // this should not be done in production. + return TurnContext.traceActivity( + turnContext, + String.format("OnTurnError Trace %s", exception.getMessage()) + ); + } + ); + }).thenApply(finalResult -> null); + + } catch (Exception ex) { + logger.error("Exception caught in sendErrorMessage", ex); + return Async.completeExceptionally(ex); + } + } + + private CompletableFuture endSkillConversation(TurnContext turnContext) { + if (skillHttpClient == null || skillsConfiguration == null) { + return CompletableFuture.completedFuture(null); + } + + // Inform the active skill that the conversation is ended so that it has a chance to clean up. + // Note: the root bot manages the ActiveSkillPropertyName, which has a value while the root bot + // has an active conversation with a skill. + StatePropertyAccessor skillAccessor = + conversationState.createProperty(MainDialog.ActiveSkillPropertyName); + // forwarded to a Skill. + return skillAccessor.get(turnContext, () -> null).thenApply(activeSkill -> { + if (activeSkill != null) { + String botId = configuration.getProperty(MicrosoftAppCredentials.MICROSOFTAPPID); + + Activity endOfConversation = Activity.createEndOfConversationActivity(); + endOfConversation.setCode(EndOfConversationCodes.ROOT_SKILL_ERROR); + endOfConversation + .applyConversationReference(turnContext.getActivity().getConversationReference(), true); + + return conversationState.saveChanges(turnContext, true).thenCompose(saveResult -> { + return skillHttpClient.postActivity( + botId, + activeSkill, + skillsConfiguration.getSkillHostEndpoint(), + endOfConversation, + Object.class + ); + + }); + } + return CompletableFuture.completedFuture(null); + }).thenApply(result -> null); + } + + private CompletableFuture clearConversationState(TurnContext turnContext) { + try { + return conversationState.delete(turnContext); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdaptiveCard.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdaptiveCard.java new file mode 100644 index 000000000..fd8724919 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/AdaptiveCard.java @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class AdaptiveCard { + @JsonProperty("$schema") + private String schema = null; + + @JsonProperty("type") + private String type = null; + + @JsonProperty("version") + private String version = null; + + @JsonProperty("body") + private List body = null; + + @JsonIgnore + private Map additionalProperties = new HashMap(); + + @JsonProperty("$schema") + public String getSchema() { + return schema; + } + + @JsonProperty("$schema") + public void setSchema(String schema) { + this.schema = schema; + } + + @JsonProperty("type") + public String getType() { + return type; + } + + @JsonProperty("type") + public void setType(String type) { + this.type = type; + } + + @JsonProperty("version") + public String getVersion() { + return version; + } + + @JsonProperty("version") + public void setVersion(String version) { + this.version = version; + } + + @JsonProperty("body") + public List getBody() { + return body; + } + + @JsonProperty("body") + public void setBody(List body) { + this.body = body; + } + + + + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperty(String name, Object value) { + this.additionalProperties.put(name, value); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Application.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Application.java new file mode 100644 index 000000000..bb2331ac1 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Application.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import com.microsoft.bot.builder.Bot; +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.Storage; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillHandler; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.connector.authentication.ChannelProvider; +import com.microsoft.bot.connector.authentication.CredentialProvider; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.integration.spring.BotController; +import com.microsoft.bot.integration.spring.BotDependencyConfiguration; +import com.microsoft.bot.sample.dialogrootbot.Bots.RootBot; +import com.microsoft.bot.sample.dialogrootbot.authentication.AllowedSkillsClaimsValidator; +import com.microsoft.bot.sample.dialogrootbot.dialogs.MainDialog; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; + +// +// This is the starting point of the Sprint Boot Bot application. +// +@SpringBootApplication + +// Use the default BotController to receive incoming Channel messages. A custom +// controller could be used by eliminating this import and creating a new +// org.springframework.web.bind.annotation.RestController. +// The default controller is created by the Spring Boot container using +// dependency injection. The default route is /api/messages. +@Import({BotController.class}) + +/** + * This class extends the BotDependencyConfiguration which provides the default + * implementations for a Bot application. The Application class should + * override methods in order to provide custom implementations. + */ +public class Application extends BotDependencyConfiguration { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /** + * Returns the Bot for this application. + * + *

+ * The @Component annotation could be used on the Bot class instead of this method + * with the @Bean annotation. + *

+ * + * @return The Bot implementation for this application. + */ + @Bean + public Bot getBot( + ConversationState conversationState, + SkillsConfiguration skillsConfig, + SkillHttpClient skillClient, + Configuration configuration, + MainDialog mainDialog + ) { + return new RootBot(conversationState, mainDialog); + } + + @Bean + public MainDialog getMainDialog( + ConversationState conversationState, + SkillConversationIdFactoryBase conversationIdFactory, + SkillHttpClient skillClient, + SkillsConfiguration skillsConfig, + Configuration configuration + ) { + return new MainDialog(conversationState, conversationIdFactory, skillClient, skillsConfig, configuration); + } + + @Primary + @Bean + public AuthenticationConfiguration getAuthenticationConfiguration( + Configuration configuration, + AllowedSkillsClaimsValidator allowedSkillsClaimsValidator + ) { + AuthenticationConfiguration authenticationConfiguration = new AuthenticationConfiguration(); + authenticationConfiguration.setClaimsValidator(allowedSkillsClaimsValidator); + return authenticationConfiguration; + } + + @Bean + public AllowedSkillsClaimsValidator getAllowedSkillsClaimsValidator(SkillsConfiguration skillsConfiguration) { + return new AllowedSkillsClaimsValidator(skillsConfiguration); + } + + /** + * Returns a custom Adapter that provides error handling. + * + * @param configuration The Configuration object to use. + * @return An error handling BotFrameworkHttpAdapter. + */ + @Bean + @Primary + public BotFrameworkHttpAdapter getBotFrameworkHttpAdaptor( + Configuration configuration, + ConversationState conversationState, + SkillHttpClient skillHttpClient, + SkillsConfiguration skillsConfiguration + ) { + return new AdapterWithErrorHandler( + configuration, + conversationState, + skillHttpClient, + skillsConfiguration); + } + + @Bean + public SkillsConfiguration getSkillsConfiguration(Configuration configuration) { + return new SkillsConfiguration(configuration); + } + + @Bean + public SkillHttpClient getSkillHttpClient( + CredentialProvider credentialProvider, + SkillConversationIdFactoryBase conversationIdFactory, + ChannelProvider channelProvider + ) { + return new SkillHttpClient(credentialProvider, conversationIdFactory, channelProvider); + } + + @Bean + public SkillConversationIdFactoryBase getSkillConversationIdFactoryBase(Storage storage) { + return new SkillConversationIdFactory(storage); + } + + @Bean public ChannelServiceHandler getChannelServiceHandler( + BotAdapter botAdapter, + Bot bot, + SkillConversationIdFactoryBase conversationIdFactory, + CredentialProvider credentialProvider, + AuthenticationConfiguration authConfig, + ChannelProvider channelProvider + ) { + return new SkillHandler( + botAdapter, + bot, + conversationIdFactory, + credentialProvider, + authConfig, + channelProvider); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Body.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Body.java new file mode 100644 index 000000000..95c09213b --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Body.java @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.HashMap; +import java.util.Map; + +public class Body { + + @JsonProperty("type") + private String type; + + @JsonProperty("url") + private String url; + + @JsonProperty("size") + private String size; + + @JsonProperty("spacing") + private String spacing; + + @JsonProperty("weight") + private String weight; + + @JsonProperty("text") + private String text; + + @JsonProperty("wrap") + private String wrap; + + @JsonProperty("maxLines") + private Integer maxLines; + + @JsonProperty("color") + private String color; + + @JsonIgnore + private Map additionalProperties = new HashMap(); + + @JsonProperty("type") + public String getType() { + return type; + } + + @JsonProperty("type") + public void setType(String type) { + this.type = type; + } + + @JsonProperty("url") + public String getUrl() { + return url; + } + + @JsonProperty("url") + public void setUrl(String url) { + this.url = url; + } + + @JsonProperty("size") + public String getSize() { + return size; + } + + @JsonProperty("size") + public void setSize(String size) { + this.size = size; + } + + @JsonProperty("spacing") + public String getSpacing() { + return spacing; + } + + @JsonProperty("spacing") + public void setSpacing(String spacing) { + this.spacing = spacing; + } + + @JsonProperty("weight") + public String getWeight() { + return weight; + } + + @JsonProperty("weight") + public void setWeight(String weight) { + this.weight = weight; + } + + @JsonProperty("text") + public String getText() { + return text; + } + + @JsonProperty("text") + public void setText(String text) { + this.text = text; + } + + @JsonProperty("wrap") + public String getWrap() { + return wrap; + } + + @JsonProperty("wrap") + public void setWrap(String wrap) { + this.wrap = wrap; + } + + @JsonProperty("maxLines") + public Integer getMaxLines() { + return maxLines; + } + + @JsonProperty("MaxLines") + public void setMaxLines(Integer maxLines) { + this.maxLines = maxLines; + } + + @JsonAnyGetter + public Map getAdditionalProperties() { + return this.additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperty(String name, Object value) { + this.additionalProperties.put(name, value); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Bots/RootBot.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Bots/RootBot.java new file mode 100644 index 000000000..227afb8c4 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/Bots/RootBot.java @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogrootbot.Bots; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.codepoetics.protonpack.collectors.CompletableFutures; +import com.microsoft.bot.builder.ActivityHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; +import com.microsoft.bot.sample.dialogrootbot.AdaptiveCard; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.Attachment; +import com.microsoft.bot.schema.ChannelAccount; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BOMInputStream; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.LoggerFactory; + +public class RootBot extends ActivityHandler { + private final ConversationState conversationState; + private final Dialog mainDialog; + + public RootBot(ConversationState conversationState, T mainDialog) { + this.conversationState = conversationState; + this.mainDialog = mainDialog; + } + + @Override + public CompletableFuture onTurn(TurnContext turnContext) { + return handleTurn(turnContext).thenCompose(result -> conversationState.saveChanges(turnContext, false)); + } + + private CompletableFuture handleTurn(TurnContext turnContext) { + if (!turnContext.getActivity().getType().equals(ActivityTypes.CONVERSATION_UPDATE)) { + // Run the Dialog with the Activity. + return Dialog.run(mainDialog, turnContext, conversationState.createProperty("DialogState")); + } else { + // Let the super.class handle the activity. + return super.onTurn(turnContext); + } + } + + @Override + protected CompletableFuture onMembersAdded(List membersAdded, TurnContext turnContext) { + + Attachment welcomeCard = createAdaptiveCardAttachment("welcomeCard.json"); + Activity activity = MessageFactory.attachment(welcomeCard); + activity.setSpeak("Welcome to the Dialog Skill Prototype!"); + + return membersAdded.stream() + .filter(member -> !StringUtils.equals(member.getId(), turnContext.getActivity().getRecipient().getId())) + .map(channel -> runWelcome(turnContext, activity)) + .collect(CompletableFutures.toFutureList()) + .thenApply(resourceResponses -> null); + } + + private CompletableFuture runWelcome(TurnContext turnContext, Activity activity) { + return turnContext.sendActivity(activity).thenAccept(resourceResponses -> { + Dialog.run(mainDialog, turnContext, conversationState.createProperty("DialogState")); + }); + } + + // Load attachment from embedded resource. + private Attachment createAdaptiveCardAttachment(String fileName) { + try { + InputStream input = getClass().getClassLoader().getResourceAsStream(fileName); + BOMInputStream bomIn = new BOMInputStream(input); + String content; + StringWriter writer = new StringWriter(); + IOUtils.copy(bomIn, writer, StandardCharsets.UTF_8); + content = writer.toString(); + bomIn.close(); + return new Attachment() { + { + setContentType("application/vnd.microsoft.card.adaptive"); + setContent(new JacksonAdapter().serializer().readValue(content, AdaptiveCard.class)); + } + }; + } catch (IOException e) { + LoggerFactory.getLogger(RootBot.class).error("createAdaptiveCardAttachment", e); + } + return new Attachment(); + } + +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillConversationIdFactory.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillConversationIdFactory.java new file mode 100644 index 000000000..f9e8cb23c --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillConversationIdFactory.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.Storage; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryOptions; +import com.microsoft.bot.builder.skills.SkillConversationReference; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.schema.ConversationReference; + +import org.apache.commons.lang3.StringUtils; + +/** + * A {@link SkillConversationIdFactory} that uses an in memory + * {@link Map{TKey,TValue}} to store and retrieve {@link ConversationReference} + * instances. + */ +public class SkillConversationIdFactory extends SkillConversationIdFactoryBase { + + private Storage storage; + + public SkillConversationIdFactory(Storage storage) { + if (storage == null) { + throw new IllegalArgumentException("Storage cannot be null."); + } + this.storage = storage; + } + + @Override + public CompletableFuture createSkillConversationId(SkillConversationIdFactoryOptions options) { + if (options == null) { + Async.completeExceptionally(new IllegalArgumentException("options cannot be null.")); + } + ConversationReference conversationReference = options.getActivity().getConversationReference(); + String skillConversationId = String.format( + "%s-%s-%s-skillconvo", + conversationReference.getConversation().getId(), + options.getBotFrameworkSkill().getId(), + conversationReference.getChannelId() + ); + + SkillConversationReference skillConversationReference = new SkillConversationReference(); + skillConversationReference.setConversationReference(conversationReference); + skillConversationReference.setOAuthScope(options.getFromBotOAuthScope()); + Map skillConversationInfo = new HashMap(); + skillConversationInfo.put(skillConversationId, skillConversationReference); + return storage.write(skillConversationInfo) + .thenCompose(result -> CompletableFuture.completedFuture(skillConversationId)); + } + + @Override + public CompletableFuture getSkillConversationReference(String skillConversationId) { + if (StringUtils.isAllBlank(skillConversationId)) { + Async.completeExceptionally(new IllegalArgumentException("skillConversationId cannot be null.")); + } + + return storage.read(new String[] {skillConversationId}).thenCompose(skillConversationInfo -> { + if (skillConversationInfo.size() > 0) { + return CompletableFuture + .completedFuture((SkillConversationReference) skillConversationInfo.get(skillConversationId)); + } else { + return CompletableFuture.completedFuture(null); + } + }); + } + + @Override + public CompletableFuture deleteConversationReference(String skillConversationId) { + return storage.delete(new String[] {skillConversationId}); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillsConfiguration.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillsConfiguration.java new file mode 100644 index 000000000..8ec38a9e7 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/SkillsConfiguration.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; + +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.integration.Configuration; + +import org.apache.commons.lang3.StringUtils; + +/** + * A helper class that loads Skills information from configuration. + */ +public class SkillsConfiguration { + + private URI skillHostEndpoint; + + private Map skills = new HashMap(); + + public SkillsConfiguration(Configuration configuration) { + + boolean noMoreEntries = false; + int indexCount = 0; + while (!noMoreEntries) { + String botID = configuration.getProperty(String.format("BotFrameworkSkills[%d].Id", indexCount)); + String botAppId = configuration.getProperty(String.format("BotFrameworkSkills[%d].AppId", indexCount)); + String skillEndPoint = + configuration.getProperty(String.format("BotFrameworkSkills[%d].SkillEndpoint", indexCount)); + if ( + StringUtils.isNotBlank(botID) && StringUtils.isNotBlank(botAppId) + && StringUtils.isNotBlank(skillEndPoint) + ) { + BotFrameworkSkill newSkill = new BotFrameworkSkill(); + newSkill.setId(botID); + newSkill.setAppId(botAppId); + try { + newSkill.setSkillEndpoint(new URI(skillEndPoint)); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + skills.put(botID, newSkill); + indexCount++; + } else { + noMoreEntries = true; + } + } + + String skillHost = configuration.getProperty("SkillhostEndpoint"); + if (!StringUtils.isEmpty(skillHost)) { + try { + skillHostEndpoint = new URI(skillHost); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + } + } + + /** + * @return the SkillHostEndpoint value as a Uri. + */ + public URI getSkillHostEndpoint() { + return this.skillHostEndpoint; + } + + /** + * @return the Skills value as a Dictionary. + */ + public Map getSkills() { + return this.skills; + } + +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/authentication/AllowedSkillsClaimsValidator.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/authentication/AllowedSkillsClaimsValidator.java new file mode 100644 index 000000000..312ce5e14 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/authentication/AllowedSkillsClaimsValidator.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogrootbot.authentication; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.ClaimsValidator; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.sample.dialogrootbot.SkillsConfiguration; + +/** + * Sample claims validator that loads an allowed list from configuration if + * presentand checks that requests are coming from allowed parent bots. + */ +public class AllowedSkillsClaimsValidator extends ClaimsValidator { + + private final List allowedSkills; + + public AllowedSkillsClaimsValidator(SkillsConfiguration skillsConfig) { + if (skillsConfig == null) { + throw new IllegalArgumentException("config cannot be null."); + } + + // Load the appIds for the configured skills (we will only allow responses from skills we have configured). + allowedSkills = new ArrayList(); + for (Map.Entry configuration : skillsConfig.getSkills().entrySet()) { + allowedSkills.add(configuration.getValue().getAppId()); + } + } + + @Override + public CompletableFuture validateClaims(Map claims) { + // If _allowedCallers contains an "*", we allow all callers. + if (SkillValidation.isSkillClaim(claims)) { + // Check that the appId claim in the skill request instanceof in the list of callers + // configured for this bot. + String appId = JwtTokenValidation.getAppIdFromClaims(claims); + if (!allowedSkills.contains(appId)) { + return Async.completeExceptionally( + new RuntimeException( + String.format("Received a request from an application with an appID of \"%s\". " + + "To enable requests from this skill, add the skill to your configuration file.", appId) + ) + ); + } + } + + return CompletableFuture.completedFuture(null); + } +} + diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/controller/SkillController.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/controller/SkillController.java new file mode 100644 index 000000000..031d2e109 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/controller/SkillController.java @@ -0,0 +1,16 @@ +package com.microsoft.bot.sample.dialogrootbot.controller; + +import com.microsoft.bot.builder.ChannelServiceHandler; +import com.microsoft.bot.integration.spring.ChannelServiceController; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = {"/api/skills"}) +public class SkillController extends ChannelServiceController { + + public SkillController(ChannelServiceHandler handler) { + super(handler); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/dialogs/MainDialog.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/dialogs/MainDialog.java new file mode 100644 index 000000000..936c5936f --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/dialogs/MainDialog.java @@ -0,0 +1,365 @@ +package com.microsoft.bot.sample.dialogrootbot.dialogs; + +import com.microsoft.bot.dialogs.BeginSkillDialogOptions; +import com.microsoft.bot.dialogs.ComponentDialog; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.SkillDialog; +import com.microsoft.bot.dialogs.SkillDialogOptions; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStep; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.dialogs.choices.Choice; +import com.microsoft.bot.dialogs.choices.FoundChoice; +import com.microsoft.bot.dialogs.prompts.ChoicePrompt; +import com.microsoft.bot.dialogs.prompts.PromptOptions; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.SkillHttpClient; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.StatePropertyAccessor; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.skills.BotFrameworkSkill; +import com.microsoft.bot.builder.skills.SkillConversationIdFactoryBase; +import com.microsoft.bot.connector.authentication.MicrosoftAppCredentials; +import com.microsoft.bot.sample.dialogrootbot.SkillsConfiguration; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.InputHints; + +import org.apache.commons.lang3.StringUtils; + +public class MainDialog extends ComponentDialog { + + // Constants used for selecting actions on the skill. + private final String SkillActionBookFlight = "BookFlight"; + private final String SkillActionBookFlightWithInputParameters = "BookFlight with input parameters"; + private final String SkillActionGetWeather = "GetWeather"; + private final String SkillActionMessage = "Message"; + + public static final String ActiveSkillPropertyName = + "com.microsoft.bot.sample.dialogrootbot.dialogs.MainDialog.ActiveSkillProperty"; + private final StatePropertyAccessor activeSkillProperty; + private final String _selectedSkillKey = + "com.microsoft.bot.sample.dialogrootbot.dialogs.MainDialog.SelectedSkillKey"; + private final SkillsConfiguration _skillsConfig; + + // Dependency injection uses this constructor to instantiate MainDialog. + public MainDialog( + ConversationState conversationState, + SkillConversationIdFactoryBase conversationIdFactory, + SkillHttpClient skillClient, + SkillsConfiguration skillsConfig, + Configuration configuration + ) { + super("MainDialog"); + String botId = configuration.getProperty(MicrosoftAppCredentials.MICROSOFTAPPID); + if (StringUtils.isEmpty(botId)) { + throw new IllegalArgumentException( + String.format("%s is not in configuration", MicrosoftAppCredentials.MICROSOFTAPPID) + ); + } + + if (skillsConfig == null) { + throw new IllegalArgumentException("skillsConfig cannot be null"); + } + + if (skillClient == null) { + throw new IllegalArgumentException("skillClient cannot be null"); + } + + if (conversationState == null) { + throw new IllegalArgumentException("conversationState cannot be null"); + } + + _skillsConfig = skillsConfig; + + // Use helper method to add SkillDialog instances for the configured skills. + addSkillDialogs(conversationState, conversationIdFactory, skillClient, skillsConfig, botId); + + // Add ChoicePrompt to render available skills. + addDialog(new ChoicePrompt("SkillPrompt")); + + // Add ChoicePrompt to render skill actions. + addDialog(new ChoicePrompt("SkillActionPrompt", (promptContext) -> { + if (!promptContext.getRecognized().getSucceeded()) { + // Assume the user wants to send a message if an item in the list is not + // selected. + FoundChoice foundChoice = new FoundChoice(); + foundChoice.setValue(SkillActionMessage); + promptContext.getRecognized().setValue(foundChoice); + } + return CompletableFuture.completedFuture(true); + }, "")); + + // Add main waterfall dialog for this bot. + WaterfallStep[] waterfallSteps = + {this::selectSkillStep, this::selectSkillActionStep, this::callSkillActionStep, this::finalStep}; + + addDialog(new WaterfallDialog("WaterfallDialog", Arrays.asList(waterfallSteps))); + + // Create state property to track the active skill. + activeSkillProperty = conversationState.createProperty(ActiveSkillPropertyName); + + // The initial child Dialog to run. + setInitialDialogId("WaterfallDialog"); + } + + @Override + protected CompletableFuture onContinueDialog(DialogContext innerDc) { + // This instanceof an example on how to cancel a SkillDialog that instanceof + // currently in progress from the parent bot. + return activeSkillProperty.get(innerDc.getContext(), null).thenCompose(activeSkill -> { + Activity activity = innerDc.getContext().getActivity(); + if ( + activeSkill != null && activity.getType().equals(ActivityTypes.MESSAGE) + && activity.getText().equals("abort") + ) { + // Cancel all dialogs when the user says abort. + // The SkillDialog automatically sends an EndOfConversation message to the skill + // to let the + // skill know that it needs to end its current dialogs, too. + return innerDc.cancelAllDialogs() + .thenCompose( + result -> innerDc + .replaceDialog(getInitialDialogId(), "Canceled! \n\n What skill would you like to call?") + ); + } + + return super.onContinueDialog(innerDc); + }); + } + + // Render a prompt to select the skill to call. + public CompletableFuture selectSkillStep(WaterfallStepContext stepContext) { + String messageText = "What skill would you like to call?"; + // Create the PromptOptions from the skill configuration which contain the list + // of configured skills. + if (stepContext.getOptions() != null) { + messageText = stepContext.getOptions().toString(); + } + String repromptMessageText = "That was not a valid choice, please select a valid skill."; + PromptOptions options = new PromptOptions(); + options.setPrompt(MessageFactory.text(messageText, messageText, InputHints.EXPECTING_INPUT)); + options + .setRetryPrompt(MessageFactory.text(repromptMessageText, repromptMessageText, InputHints.EXPECTING_INPUT)); + + List choicesList = new ArrayList(); + for (BotFrameworkSkill skill : _skillsConfig.getSkills().values()) { + choicesList.add(new Choice(skill.getId())); + } + options.setChoices(choicesList); + + // Prompt the user to select a skill. + return stepContext.prompt("SkillPrompt", options); + } + + // Render a prompt to select the action for the skill. + public CompletableFuture selectSkillActionStep(WaterfallStepContext stepContext) { + // Get the skill info super. on the selected skill. + String selectedSkillId = ((FoundChoice) stepContext.getResult()).getValue(); + BotFrameworkSkill selectedSkill = _skillsConfig.getSkills() + .values() + .stream() + .filter(x -> x.getId().equals(selectedSkillId)) + .findFirst() + .get(); + + // Remember the skill selected by the user. + stepContext.getValues().put(_selectedSkillKey, selectedSkill); + + // Create the PromptOptions with the actions supported by the selected skill. + String messageText = String.format( + "Select an action # to send to **%n** or just type in a " + "message and it will be forwarded to the skill", + selectedSkill.getId() + ); + PromptOptions options = new PromptOptions(); + options.setPrompt(MessageFactory.text(messageText, messageText, InputHints.EXPECTING_INPUT)); + options.setChoices(getSkillActions(selectedSkill)); + + // Prompt the user to select a skill action. + return stepContext.prompt("SkillActionPrompt", options); + } + + // Starts the SkillDialog super. on the user's selections. + public CompletableFuture callSkillActionStep(WaterfallStepContext stepContext) { + BotFrameworkSkill selectedSkill = (BotFrameworkSkill) stepContext.getValues().get(_selectedSkillKey); + + Activity skillActivity; + switch (selectedSkill.getId()) { + case "DialogSkillBot": + skillActivity = createDialogSkillBotActivity( + ((FoundChoice) stepContext.getResult()).getValue(), + stepContext.getContext() + ); + break; + + // We can add other case statements here if we support more than one skill. + default: + throw new RuntimeException(String.format("Unknown target skill id: %s.", selectedSkill.getId())); + } + + // Create the BeginSkillDialogOptions and assign the activity to send. + BeginSkillDialogOptions skillDialogArgs = new BeginSkillDialogOptions(); + skillDialogArgs.setActivity(skillActivity); + + // Save active skill in state. + activeSkillProperty.set(stepContext.getContext(), selectedSkill); + + // Start the skillDialog instance with the arguments. + return stepContext.beginDialog(selectedSkill.getId(), skillDialogArgs); + } + + // The SkillDialog has ended, render the results (if any) and restart + // MainDialog. + public CompletableFuture finalStep(WaterfallStepContext stepContext) { + return activeSkillProperty.get(stepContext.getContext(), () -> null).thenCompose(activeSkill -> { + if (stepContext.getResult() != null) { + String jsonResult = ""; + try { + jsonResult = + new JacksonAdapter().serialize(stepContext.getResult()).replace("{", "").replace("}", ""); + } catch (IOException e) { + e.printStackTrace(); + } + String message = + String.format("Skill \"%s\" invocation complete. Result: %s", activeSkill.getId(), jsonResult); + stepContext.getContext().sendActivity(MessageFactory.text(message, message, InputHints.IGNORING_INPUT)); + } + + // Clear the skill selected by the user. + stepContext.getValues().put(_selectedSkillKey, null); + + // Clear active skill in state. + activeSkillProperty.delete(stepContext.getContext()); + + // Restart the main dialog with a different message the second time around. + return stepContext.replaceDialog( + getInitialDialogId(), + String.format("Done with \"%s\". \n\n What skill would you like to call?", activeSkill.getId()) + ); + }); + // Check if the skill returned any results and display them. + } + + // Helper method that creates and adds SkillDialog instances for the configured + // skills. + private void addSkillDialogs( + ConversationState conversationState, + SkillConversationIdFactoryBase conversationIdFactory, + SkillHttpClient skillClient, + SkillsConfiguration skillsConfig, + String botId + ) { + for (BotFrameworkSkill skillInfo : _skillsConfig.getSkills().values()) { + // Create the dialog options. + SkillDialogOptions skillDialogOptions = new SkillDialogOptions(); + skillDialogOptions.setBotId(botId); + skillDialogOptions.setConversationIdFactory(conversationIdFactory); + skillDialogOptions.setSkillClient(skillClient); + skillDialogOptions.setSkillHostEndpoint(skillsConfig.getSkillHostEndpoint()); + skillDialogOptions.setConversationState(conversationState); + skillDialogOptions.setSkill(skillInfo); + // Add a SkillDialog for the selected skill. + addDialog(new SkillDialog(skillDialogOptions, skillInfo.getId())); + } + } + + // Helper method to create Choice elements for the actions supported by the + // skill. + private List getSkillActions(BotFrameworkSkill skill) { + // Note: the bot would probably render this by reading the skill manifest. + // We are just using hardcoded skill actions here for simplicity. + + List choices = new ArrayList(); + switch (skill.getId()) { + case "DialogSkillBot": + choices.add(new Choice(SkillActionBookFlight)); + choices.add(new Choice(SkillActionBookFlightWithInputParameters)); + choices.add(new Choice(SkillActionGetWeather)); + break; + } + + return choices; + } + + // Helper method to create the activity to be sent to the DialogSkillBot using + // selected type and values. + private Activity createDialogSkillBotActivity(String selectedOption, TurnContext turnContext) { + + // Note: in a real bot, the dialogArgs will be created dynamically super. on the + // conversation + // and what each action requires; here we hardcode the values to make things + // simpler. + ObjectMapper mapper = new ObjectMapper(); + Activity activity = null; + + // Just forward the message activity to the skill with whatever the user said. + if (selectedOption.equalsIgnoreCase(SkillActionMessage)) { + // Note message activities also support input parameters but we are not using + // them in this example. + // Return a deep clone of the activity so we don't risk altering the original + // one + activity = Activity.clone(turnContext.getActivity()); + } + + // Send an event activity to the skill with "BookFlight" in the name. + if (selectedOption.equalsIgnoreCase(SkillActionBookFlight)) { + activity = Activity.createEventActivity(); + activity.setName(SkillActionBookFlight); + } + + // Send an event activity to the skill with "BookFlight" in the name and some + // testing values. + if (selectedOption.equalsIgnoreCase(SkillActionBookFlightWithInputParameters)) { + activity = Activity.createEventActivity(); + activity.setName(SkillActionBookFlight); + try { + activity.setValue( + mapper.readValue("{ \"origin\": \"New York\", \"destination\": \"Seattle\"}", Object.class) + ); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + + // Send an event activity to the skill with "GetWeather" in the name and some + // testing values. + if (selectedOption.equalsIgnoreCase(SkillActionGetWeather)) { + activity = Activity.createEventActivity(); + activity.setName(SkillActionGetWeather); + try { + activity + .setValue(mapper.readValue("{ \"latitude\": 47.614891, \"longitude\": -122.195801}", Object.class)); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + + if (activity == null) { + throw new RuntimeException(String.format("Unable to create a skill activity for \"%s\".", selectedOption)); + } + + // We are manually creating the activity to send to the skill; ensure we add the + // ChannelData and Properties + // from the original activity so the skill gets them. + // Note: this instanceof not necessary if we are just forwarding the current + // activity from context. + activity.setChannelData(turnContext.getActivity().getChannelData()); + for (String key : turnContext.getActivity().getProperties().keySet()) { + activity.setProperties(key, turnContext.getActivity().getProperties().get(key)); + } + + return activity; + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/ConsoleLogger.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/ConsoleLogger.java new file mode 100644 index 000000000..f2391d63a --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/ConsoleLogger.java @@ -0,0 +1,11 @@ +package com.microsoft.bot.sample.dialogrootbot.middleware; + +import java.util.concurrent.CompletableFuture; + +public class ConsoleLogger extends Logger { + + @Override + public CompletableFuture logEntry(String entryToLog) { + return super.logEntry(entryToLog); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/Logger.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/Logger.java new file mode 100644 index 000000000..ed980f188 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/Logger.java @@ -0,0 +1,11 @@ +package com.microsoft.bot.sample.dialogrootbot.middleware; + +import java.util.concurrent.CompletableFuture; + +public abstract class Logger { + + public CompletableFuture logEntry(String entryToLog) { + System.out.println(entryToLog); + return CompletableFuture.completedFuture(null); + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/LoggerMiddleware.java b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/LoggerMiddleware.java new file mode 100644 index 000000000..3593d6c3a --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/java/com/microsoft/bot/sample/dialogrootbot/middleware/LoggerMiddleware.java @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. +package com.microsoft.bot.sample.dialogrootbot.middleware; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.Middleware; +import com.microsoft.bot.builder.NextDelegate; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + + +/** + * Uses an Logger instance to log user and bot messages. It filters out + * ContinueConversation events coming from skill responses. + */ +public class LoggerMiddleware implements Middleware { + + private final Logger _logger; + + public LoggerMiddleware(Logger logger) { + if (logger == null) { + throw new IllegalArgumentException("Logger cannot be null"); + } + _logger = logger; + } + + public CompletableFuture onTurn(TurnContext turnContext, NextDelegate next) { + // Note: skill responses will show as ContinueConversation events; we don't log those. + // We only log incoming messages from users. + if (!turnContext.getActivity().getType().equals(ActivityTypes.EVENT) + && turnContext.getActivity().getName() != null + && turnContext.getActivity().getName().equals("ContinueConversation")) { + String message = String.format("User said: %s Type: \"%s\" Name: \"%s\"", + turnContext.getActivity().getText(), + turnContext.getActivity().getType(), + turnContext.getActivity().getName()); + _logger.logEntry(message); + } + + // Register outgoing handler. + // hook up onSend pipeline + turnContext.onSendActivities( + (ctx, activities, nextSend) -> { + // run full pipeline + return nextSend.get().thenApply(responses -> { + for (Activity activity : activities) { + String message = String.format("Bot said: %s Type: \"%s\" Name: \"%s\"", + activity.getText(), + activity.getType(), + activity.getName()); + _logger.logEntry(message); + } + return responses; + }); + } + ); + + if (next != null) { + return next.next(); + } + + return CompletableFuture.completedFuture(null); + } +} + diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/application.properties b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/application.properties new file mode 100644 index 000000000..2feae73a0 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/application.properties @@ -0,0 +1,8 @@ +MicrosoftAppId= +MicrosoftAppPassword= +server.port=3978 +SkillhostEndpoint=http://localhost:3978/api/skills/ +#replicate these three entries, incrementing the index value [0] for each successive Skill that is added. +BotFrameworkSkills[0].Id=DialogSkillBot +BotFrameworkSkills[0].AppId= +BotFrameworkSkills[0].SkillEndpoint=http://localhost:39783/api/messages diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/log4j2.json b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/log4j2.json new file mode 100644 index 000000000..67c0ad530 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/log4j2.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "name": "Default", + "appenders": { + "Console": { + "name": "Console-Appender", + "target": "SYSTEM_OUT", + "PatternLayout": {"pattern": "[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n"} + } + }, + "loggers": { + "root": { + "level": "debug", + "appender-ref": {"ref": "Console-Appender","level": "debug"} + } + } + } +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/welcomeCard.json b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/welcomeCard.json new file mode 100644 index 000000000..eab77b256 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/resources/welcomeCard.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "Medium", + "size": "Medium", + "weight": "Bolder", + "text": "Welcome to the Skill Dialog Sample!", + "wrap": true, + "maxLines": 0, + "color": "Accent" + }, + { + "type": "TextBlock", + "size": "default", + "text": "This sample allows you to connect to a skill using a SkillDialog and invoke several actions.", + "wrap": true, + "maxLines": 0 + } + ] +} diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/META-INF/MANIFEST.MF b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..254272e1c --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: + diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/WEB-INF/web.xml b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..383c19004 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + dispatcher + + org.springframework.web.servlet.DispatcherServlet + + + contextConfigLocation + /WEB-INF/spring/dispatcher-config.xml + + 1 + \ No newline at end of file diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/index.html b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/index.html new file mode 100644 index 000000000..d5ba5158e --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/main/webapp/index.html @@ -0,0 +1,418 @@ + + + + + + + EchoBot + + + + + +
+
+
+
Spring Boot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + diff --git a/samples/81.skills-skilldialog/dialog-root-bot/src/test/java/com/microsoft/bot/sample/dialogrootbot/ApplicationTest.java b/samples/81.skills-skilldialog/dialog-root-bot/src/test/java/com/microsoft/bot/sample/dialogrootbot/ApplicationTest.java new file mode 100644 index 000000000..b633cebc7 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-root-bot/src/test/java/com/microsoft/bot/sample/dialogrootbot/ApplicationTest.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogrootbot; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } + +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/.vscode/settings.json b/samples/81.skills-skilldialog/dialog-skill-bot/.vscode/settings.json new file mode 100644 index 000000000..e0f15db2e --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/LICENSE b/samples/81.skills-skilldialog/dialog-skill-bot/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/README.md b/samples/81.skills-skilldialog/dialog-skill-bot/README.md new file mode 100644 index 000000000..19c717938 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/README.md @@ -0,0 +1,3 @@ +# EchoSkillBot + +See [81.skills-skilldialog](../Readme.md) for details on how to configure and run this sample. diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json b/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json new file mode 100644 index 000000000..ec2460d3a --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-new-rg.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "groupLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "Specifies the location of the Resource Group." + } + }, + "groupName": { + "type": "string", + "metadata": { + "description": "Specifies the name of the Resource Group." + } + }, + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The name of the App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "newAppServicePlanLocation": { + "defaultValue": "", + "type": "string", + "metadata": { + "description": "The location of the App Service Plan. Defaults to \"westus\"." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "appServicePlanName": "[parameters('newAppServicePlanName')]", + "resourcesLocation": "[parameters('newAppServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourceGroupId": "[concat(subscription().id, '/resourceGroups/', parameters('groupName'))]" + }, + "resources": [ + { + "name": "[parameters('groupName')]", + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2018-05-01", + "location": "[parameters('groupLocation')]", + "properties": {} + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2018-05-01", + "name": "storageDeployment", + "resourceGroup": "[parameters('groupName')]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/resourceGroups/', parameters('groupName'))]" + ], + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "variables": {}, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "name": "[variables('appServicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/serverfarms/', variables('appServicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[variables('appServicePlanName')]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[concat(variables('resourceGroupId'), '/providers/Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ], + "outputs": {} + } + } + } + ] +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json b/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json new file mode 100644 index 000000000..024dcf08d --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/deploymentTemplates/template-with-preexisting-rg.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appId": { + "type": "string", + "metadata": { + "description": "Active Directory App ID, set as MicrosoftAppId in the Web App's Application Settings." + } + }, + "appSecret": { + "type": "string", + "metadata": { + "description": "Active Directory App Password, set as MicrosoftAppPassword in the Web App's Application Settings. Defaults to \"\"." + } + }, + "botId": { + "type": "string", + "metadata": { + "description": "The globally unique and immutable bot ID. Also used to configure the displayName of the bot, which is mutable." + } + }, + "botSku": { + "defaultValue": "S1", + "type": "string", + "metadata": { + "description": "The pricing tier of the Bot Service Registration. Acceptable values are F0 and S1." + } + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "P1v2", + "tier": "PremiumV2", + "size": "P1v2", + "family": "Pv2", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "newWebAppName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "The globally unique name of the Web App. Defaults to the value passed in for \"botId\"." + } + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "publishingUsername": "[concat('$', parameters('newWebAppName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "webAppName": "[if(empty(parameters('newWebAppName')), parameters('botId'), parameters('newWebAppName'))]", + "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", + "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]" + }, + "resources": [ + { + "comments": "Create a new Linux App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "kind": "linux", + "properties": { + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "isSpot": false, + "reserved": true, + "isXenon": false, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + } + }, + { + "comments": "Create a Web App using a Linux App Service Plan", + "type": "Microsoft.Web/sites", + "apiVersion": "2018-11-01", + "location": "[variables('resourcesLocation')]", + "kind": "app,linux", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]" + ], + "name": "[variables('webAppName')]", + "properties": { + "name": "[variables('webAppName')]", + "hostNameSslStates": [ + { + "name": "[concat(parameters('newWebAppName'), '.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Standard" + }, + { + "name": "[concat(parameters('newWebAppName'), '.scm.azurewebsites.net')]", + "sslState": "Disabled", + "hostType": "Repository" + } + ], + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('servicePlanName'))]", + "reserved": true, + "isXenon": false, + "hyperV": false, + "scmSiteAlsoStopped": false, + "clientAffinityEnabled": true, + "clientCertEnabled": false, + "hostNamesDisabled": false, + "containerSize": 0, + "dailyMemoryTimeQuota": 0, + "httpsOnly": false, + "redundancyMode": "None", + "siteConfig": { + "appSettings": [ + { + "name": "JAVA_OPTS", + "value": "-Dserver.port=80" + }, + { + "name": "MicrosoftAppId", + "value": "[parameters('appId')]" + }, + { + "name": "MicrosoftAppPassword", + "value": "[parameters('appSecret')]" + } + ], + "cors": { + "allowedOrigins": [ + "https://botservice.hosting.portal.azure.net", + "https://hosting.onecloud.azure-test.net/" + ] + } + } + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2018-11-01", + "name": "[concat(variables('webAppName'), '/web')]", + "location": "[variables('resourcesLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ], + "properties": { + "numberOfWorkers": 1, + "defaultDocuments": [ + "Default.htm", + "Default.html", + "Default.asp", + "index.htm", + "index.html", + "iisstart.htm", + "default.aspx", + "index.php", + "hostingstart.html" + ], + "netFrameworkVersion": "v4.0", + "linuxFxVersion": "JAVA|8-jre8", + "requestTracingEnabled": false, + "remoteDebuggingEnabled": false, + "httpLoggingEnabled": false, + "logsDirectorySizeLimit": 35, + "detailedErrorLoggingEnabled": false, + "publishingUsername": "[variables('publishingUsername')]", + "scmType": "None", + "use32BitWorkerProcess": true, + "webSocketsEnabled": false, + "alwaysOn": true, + "managedPipelineMode": "Integrated", + "virtualApplications": [ + { + "virtualPath": "/", + "physicalPath": "site\\wwwroot", + "preloadEnabled": true + } + ], + "loadBalancing": "LeastRequests", + "experiments": { + "rampUpRules": [] + }, + "autoHealEnabled": false, + "localMySqlEnabled": false, + "ipSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictions": [ + { + "ipAddress": "Any", + "action": "Allow", + "priority": 1, + "name": "Allow all", + "description": "Allow all access" + } + ], + "scmIpSecurityRestrictionsUseMain": false, + "http20Enabled": false, + "minTlsVersion": "1.2", + "ftpsState": "AllAllowed", + "reservedInstanceCount": 0 + } + }, + { + "apiVersion": "2017-12-01", + "type": "Microsoft.BotService/botServices", + "name": "[parameters('botId')]", + "location": "global", + "kind": "bot", + "sku": { + "name": "[parameters('botSku')]" + }, + "properties": { + "name": "[parameters('botId')]", + "displayName": "[parameters('botId')]", + "endpoint": "[variables('botEndpoint')]", + "msaAppId": "[parameters('appId')]", + "developerAppInsightsApplicationId": null, + "developerAppInsightKey": null, + "publishingCredentials": null, + "storageResourceId": null + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites/', variables('webAppName'))]" + ] + } + ] +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/pom.xml b/samples/81.skills-skilldialog/dialog-skill-bot/pom.xml new file mode 100644 index 000000000..06077f356 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/pom.xml @@ -0,0 +1,248 @@ + + + + 4.0.0 + + com.microsoft.bot.sample + dialogSkillbot + sample + jar + + ${project.groupId}:${project.artifactId} + This package contains a Java Dialog Skill Bot sample using Spring Boot. + http://maven.apache.org + + + org.springframework.boot + spring-boot-starter-parent + 2.4.0 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + + + + + + Bot Framework Development + + Microsoft + https://dev.botframework.com/ + + + + + 1.8 + 1.8 + 1.8 + com.microsoft.bot.sample.dialogskillbot.Application + + + + + junit + junit + 4.13.1 + test + + + org.springframework.boot + spring-boot-starter-test + 2.4.0 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-api + 2.11.0 + + + org.apache.logging.log4j + log4j-core + 2.13.2 + + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview9 + compile + + + com.microsoft.bot + bot-dialogs + 4.6.0-preview9 + compile + + + com.microsoft.bot + bot-ai-luis-v3 + 4.6.0-preview9 + + + + + build + + true + + + + + src/main/resources + false + + + + + maven-compiler-plugin + 3.8.1 + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + com.microsoft.bot.sample.dialogskillbot.Application + + + + + + com.microsoft.azure + azure-webapp-maven-plugin + 1.12.0 + + V2 + ${groupname} + ${botname} + + + JAVA_OPTS + -Dserver.port=80 + + + + linux + Java 8 + Java SE + + + + + ${project.basedir}/target + + *.jar + + + + + + + + + + + + publish + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + maven-war-plugin + 3.2.3 + + src/main/webapp + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + true + ossrh + https://oss.sonatype.org/ + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + false + + + + attach-javadocs + + jar + + + + + + + + + diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/Application.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/Application.java new file mode 100644 index 000000000..61f160d22 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/Application.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogskillbot; + +import com.microsoft.bot.builder.Bot; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.integration.spring.BotController; +import com.microsoft.bot.integration.spring.BotDependencyConfiguration; +import com.microsoft.bot.sample.dialogskillbot.authentication.AllowedCallersClaimsValidator; +import com.microsoft.bot.sample.dialogskillbot.bots.SkillBot; +import com.microsoft.bot.sample.dialogskillbot.dialogs.ActivityRouterDialog; +import com.microsoft.bot.sample.dialogskillbot.dialogs.DialogSkillBotRecognizer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +// +// This is the starting point of the Sprint Boot Bot application. +// +@SpringBootApplication + +// Use the default BotController to receive incoming Channel messages. A custom +// controller could be used by eliminating this import and creating a new +// org.springframework.web.bind.annotation.RestController. +// The default controller is created by the Spring Boot container using +// dependency injection. The default route is /api/messages. +@Import({BotController.class}) + +/** + * This class extends the BotDependencyConfiguration which provides the default + * implementations for a Bot application. The Application class should override + * methods in order to provide custom implementations. + */ +public class Application extends BotDependencyConfiguration { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + /** + * Returns the Bot for this application. + * + *

+ * The @Component annotation could be used on the Bot class instead of this + * method with the @Bean annotation. + *

+ * + * @return The Bot implementation for this application. + */ + @Bean + public Bot getBot(Configuration configuration, ConversationState converationState) { + + return new SkillBot( + converationState, + new ActivityRouterDialog(new DialogSkillBotRecognizer(configuration)) + ); + } + + @Override + public AuthenticationConfiguration getAuthenticationConfiguration(Configuration configuration) { + AuthenticationConfiguration authenticationConfiguration = new AuthenticationConfiguration(); + authenticationConfiguration.setClaimsValidator(new AllowedCallersClaimsValidator(configuration)); + return authenticationConfiguration; + } + + /** + * Returns a custom Adapter that provides error handling. + * + * @param configuration The Configuration object to use. + * @return An error handling BotFrameworkHttpAdapter. + */ + @Override + public BotFrameworkHttpAdapter getBotFrameworkHttpAdaptor(Configuration configuration) { + return new SkillAdapterWithErrorHandler(configuration, getAuthenticationConfiguration(configuration)); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/SkillAdapterWithErrorHandler.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/SkillAdapterWithErrorHandler.java new file mode 100644 index 000000000..363326726 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/SkillAdapterWithErrorHandler.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. +package com.microsoft.bot.sample.dialogskillbot; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.OnTurnErrorHandler; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.AuthenticationConfiguration; +import com.microsoft.bot.integration.BotFrameworkHttpAdapter; +import com.microsoft.bot.integration.Configuration; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.EndOfConversationCodes; +import com.microsoft.bot.schema.InputHints; + +public class SkillAdapterWithErrorHandler extends BotFrameworkHttpAdapter { + + public SkillAdapterWithErrorHandler( + Configuration configuration, + AuthenticationConfiguration authenticationConfiguration + ) { + super(configuration, authenticationConfiguration); + setOnTurnError(new SkillAdapterErrorHandler()); + } + + private class SkillAdapterErrorHandler implements OnTurnErrorHandler { + + @Override + public CompletableFuture invoke(TurnContext turnContext, Throwable exception) { + return sendErrorMessage(turnContext, exception).thenAccept(result -> { + sendEoCToParent(turnContext, exception); + }); + } + + private CompletableFuture sendErrorMessage(TurnContext turnContext, Throwable exception) { + try { + // Send a message to the user. + String errorMessageText = "The skill encountered an error or bug."; + Activity errorMessage = + MessageFactory.text(errorMessageText, errorMessageText, InputHints.IGNORING_INPUT); + return turnContext.sendActivity(errorMessage).thenAccept(result -> { + String secondLineMessageText = "To continue to run this bot, please fix the bot source code."; + Activity secondErrorMessage = + MessageFactory.text(secondLineMessageText, secondLineMessageText, InputHints.EXPECTING_INPUT); + turnContext.sendActivity(secondErrorMessage) + .thenApply( + sendResult -> { + // Send a trace activity, which will be displayed in the Bot Framework Emulator. + // Note: we return the entire exception in the value property to help the + // developer; + // this should not be done in production. + return TurnContext.traceActivity( + turnContext, + String.format("OnTurnError Trace %s", exception.toString()) + ); + + } + ); + }); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + private CompletableFuture sendEoCToParent(TurnContext turnContext, Throwable exception) { + try { + // Send an EndOfConversation activity to the skill caller with the error to end + // the conversation, + // and let the caller decide what to do. + Activity endOfConversation = Activity.createEndOfConversationActivity(); + endOfConversation.setCode(EndOfConversationCodes.SKILL_ERROR); + endOfConversation.setText(exception.getMessage()); + return turnContext.sendActivity(endOfConversation).thenApply(result -> null); + } catch (Exception ex) { + return Async.completeExceptionally(ex); + } + } + + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/authentication/AllowedCallersClaimsValidator.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/authentication/AllowedCallersClaimsValidator.java new file mode 100644 index 000000000..c15c81cff --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/authentication/AllowedCallersClaimsValidator.java @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.authentication; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.connector.Async; +import com.microsoft.bot.connector.authentication.ClaimsValidator; +import com.microsoft.bot.connector.authentication.JwtTokenValidation; +import com.microsoft.bot.connector.authentication.SkillValidation; +import com.microsoft.bot.integration.Configuration; + +/** + * Sample claims validator that loads an allowed list from configuration if + * presentand checks that requests are coming from allowed parent bots. + */ +public class AllowedCallersClaimsValidator extends ClaimsValidator { + + private final String configKey = "AllowedCallers"; + private final List allowedCallers; + + public AllowedCallersClaimsValidator(Configuration config) { + if (config == null) { + throw new IllegalArgumentException("config cannot be null."); + } + + // AllowedCallers instanceof the setting in the application.properties file + // that consists of the list of parent bot Ds that are allowed to access the + // skill. + // To add a new parent bot, simply edit the AllowedCallers and add + // the parent bot's Microsoft app ID to the list. + // In this sample, we allow all callers if AllowedCallers contains an "*". + String[] appsList = config.getProperties(configKey); + if (appsList == null) { + throw new IllegalStateException(String.format("\"%s\" not found in configuration.", configKey)); + } + + allowedCallers = Arrays.asList(appsList); + } + + @Override + public CompletableFuture validateClaims(Map claims) { + // If _allowedCallers contains an "*", we allow all callers. + if (SkillValidation.isSkillClaim(claims) && !allowedCallers.contains("*")) { + // Check that the appId claim in the skill request instanceof in the list of + // callers configured for this bot. + String appId = JwtTokenValidation.getAppIdFromClaims(claims); + if (!allowedCallers.contains(appId)) { + return Async.completeExceptionally( + new RuntimeException( + String.format( + "Received a request from a bot with an app ID of \"%s\". " + + "To enable requests from this caller, add the app ID to your configuration file.", + appId + ) + ) + ); + } + } + + return CompletableFuture.completedFuture(null); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/bots/SkillBot.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/bots/SkillBot.java new file mode 100644 index 000000000..2c124cb86 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/bots/SkillBot.java @@ -0,0 +1,31 @@ +package com.microsoft.bot.sample.dialogskillbot.bots; + +import java.util.concurrent.CompletableFuture; + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import com.microsoft.bot.builder.ActivityHandler; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.dialogs.Dialog; + +public class SkillBot extends ActivityHandler { + + private ConversationState conversationState; + private Dialog dialog; + + public SkillBot(ConversationState conversationState, T mainDialog) { + this.conversationState = conversationState; + this.dialog = mainDialog; + } + + @Override + public CompletableFuture onTurn(TurnContext turnContext) { + return Dialog.run(dialog, turnContext, conversationState.createProperty("DialogState")) + .thenAccept(result -> { + conversationState.saveChanges(turnContext, false); + }); + } + +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/cognitivemodels/flightbooking.json b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/cognitivemodels/flightbooking.json new file mode 100644 index 000000000..8e25098df --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/cognitivemodels/flightbooking.json @@ -0,0 +1,339 @@ +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "FlightBooking", + "desc": "Luis Model for CoreBot", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "BookFlight" + }, + { + "name": "Cancel" + }, + { + "name": "GetWeather" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris", + "cdg" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london", + "lhr" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin", + "txl" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york", + "jfk" + ] + }, + { + "canonicalForm": "Seattle", + "list": [ + "seattle", + "sea" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book a flight", + "intent": "BookFlight", + "entities": [] + }, + { + "text": "book a flight from new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 26 + } + ] + }, + { + "text": "book a flight from seattle", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 25 + } + ] + }, + { + "text": "book a hotel in new york", + "intent": "None", + "entities": [] + }, + { + "text": "book a restaurant", + "intent": "None", + "entities": [] + }, + { + "text": "book flight from london to paris on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 17, + "endPos": 22 + }, + { + "entity": "To", + "startPos": 27, + "endPos": 31 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 22, + "endPos": 27 + }, + { + "entity": "To", + "startPos": 32, + "endPos": 36 + } + ] + }, + { + "text": "bye", + "intent": "Cancel", + "entities": [] + }, + { + "text": "cancel booking", + "intent": "Cancel", + "entities": [] + }, + { + "text": "exit", + "intent": "Cancel", + "entities": [] + }, + { + "text": "find an airport near me", + "intent": "None", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 9, + "endPos": 14 + }, + { + "entity": "To", + "startPos": 19, + "endPos": 23 + } + ] + }, + { + "text": "go to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 11, + "endPos": 15 + }, + { + "entity": "To", + "startPos": 20, + "endPos": 25 + } + ] + }, + { + "text": "i'd like to rent a car", + "intent": "None", + "entities": [] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel from new york to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 12, + "endPos": 19 + }, + { + "entity": "To", + "startPos": 24, + "endPos": 28 + } + ] + }, + { + "text": "travel to new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 17 + } + ] + }, + { + "text": "travel to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "what's the forecast for this friday?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like for tomorrow", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like in new york", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "winter is coming", + "intent": "None", + "entities": [] + } + ], + "settings": [] + } diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/ActivityRouterDialog.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/ActivityRouterDialog.java new file mode 100644 index 000000000..a0a046dd1 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/ActivityRouterDialog.java @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.builder.RecognizerResult; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.dialogs.ComponentDialog; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.DialogTurnStatus; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStep; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.bot.schema.Serialization; + +/** + * A root dialog that can route activities sent to the skill to different + * sub-dialogs. + */ +public class ActivityRouterDialog extends ComponentDialog { + + private final DialogSkillBotRecognizer luisRecognizer; + + public ActivityRouterDialog(DialogSkillBotRecognizer luisRecognizer) { + super("ActivityRouterDialog"); + this.luisRecognizer = luisRecognizer; + + addDialog(new BookingDialog()); + List stepList = new ArrayList(); + stepList.add(this::processActivity); + addDialog(new WaterfallDialog("WaterfallDialog", stepList)); + + // The initial child Dialog to run. + setInitialDialogId("WaterfallDialog"); + } + + private CompletableFuture processActivity(WaterfallStepContext stepContext) { + // A skill can send trace activities, if needed. + TurnContext.traceActivity( + stepContext.getContext(), + String.format( + "{%s}.processActivity() Got ActivityType: %s", + this.getClass().getName(), + stepContext.getContext().getActivity().getType() + ) + ); + + switch (stepContext.getContext().getActivity().getType()) { + case ActivityTypes.EVENT: + return onEventActivity(stepContext); + + case ActivityTypes.MESSAGE: + return onMessageActivity(stepContext); + + default: + String defaultMessage = String + .format("Unrecognized ActivityType: \"%s\".", stepContext.getContext().getActivity().getType()); + // We didn't get an activity type we can handle. + return stepContext.getContext() + .sendActivity(MessageFactory.text(defaultMessage, defaultMessage, InputHints.IGNORING_INPUT)) + .thenCompose(result -> { + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.COMPLETE)); + }); + + } + } + + // This method performs different tasks super. on the event name. + private CompletableFuture onEventActivity(WaterfallStepContext stepContext) { + Activity activity = stepContext.getContext().getActivity(); + TurnContext.traceActivity( + stepContext.getContext(), + String.format( + "%s.onEventActivity(), label: %s, Value: %s", + this.getClass().getName(), + activity.getName(), + GetObjectAsJsonString(activity.getValue()) + ) + ); + + // Resolve what to execute super. on the event name. + switch (activity.getName()) { + case "BookFlight": + return beginBookFlight(stepContext); + + case "GetWeather": + return beginGetWeather(stepContext); + + default: + String message = String.format("Unrecognized EventName: \"%s\".", activity.getName()); + // We didn't get an event name we can handle. + stepContext.getContext().sendActivity(MessageFactory.text(message, message, InputHints.IGNORING_INPUT)); + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.COMPLETE)); + } + } + + // This method just gets a message activity and runs it through LUS. + private CompletableFuture onMessageActivity(WaterfallStepContext stepContext) { + Activity activity = stepContext.getContext().getActivity(); + TurnContext.traceActivity( + stepContext.getContext(), + String.format( + "%s.onMessageActivity(), label: %s, Value: %s", + this.getClass().getName(), + activity.getName(), + GetObjectAsJsonString(activity.getValue()) + ) + ); + + if (!luisRecognizer.getIsConfigured()) { + String message = "NOTE: LUIS instanceof not configured. To enable all capabilities, add 'LuisAppId'," + + " 'LuisAPKey' and 'LuisAPHostName' to the appsettings.json file."; + return stepContext.getContext() + .sendActivity(MessageFactory.text(message, message, InputHints.IGNORING_INPUT)) + .thenCompose( + result -> CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.COMPLETE)) + ); + } else { + // Call LUIS with the utterance. + return luisRecognizer.recognize(stepContext.getContext(), RecognizerResult.class) + .thenCompose(luisResult -> { + // Create a message showing the LUS results. + StringBuilder sb = new StringBuilder(); + sb.append(String.format("LUIS results for \"%s\":", activity.getText())); + + sb.append( + String.format( + "Intent: \"%s\" Score: %s", + luisResult.getTopScoringIntent().intent, + luisResult.getTopScoringIntent().score + ) + ); + + return stepContext.getContext() + .sendActivity(MessageFactory.text(sb.toString(), sb.toString(), InputHints.IGNORING_INPUT)) + .thenCompose(result -> { + switch (luisResult.getTopScoringIntent().intent.toLowerCase()) { + case "bookflight": + return beginBookFlight(stepContext); + + case "getweather": + return beginGetWeather(stepContext); + + default: + // Catch all for unhandled intents. + String didntUnderstandMessageText = String.format( + "Sorry, I didn't get that. Please try asking in a different " + + "way (intent was %s)", + luisResult.getTopScoringIntent().intent + ); + Activity didntUnderstandMessage = MessageFactory.text( + didntUnderstandMessageText, + didntUnderstandMessageText, + InputHints.IGNORING_INPUT + ); + return stepContext.getContext() + .sendActivity(didntUnderstandMessage) + .thenCompose( + stepResult -> CompletableFuture + .completedFuture(new DialogTurnResult(DialogTurnStatus.COMPLETE)) + ); + + } + }); + // Start a dialog if we recognize the intent. + }); + } + } + + private static CompletableFuture beginGetWeather(WaterfallStepContext stepContext) { + Activity activity = stepContext.getContext().getActivity(); + Location location = new Location(); + if (activity.getValue() != null) { + try { + location = Serialization.safeGetAs(activity.getValue(), Location.class); + } catch (JsonProcessingException e) { + // something went wrong, so we create an empty Location so we won't get a null + // reference below when we acess location. + location = new Location(); + } + + } + + // We haven't implemented the GetWeatherDialog so we just display a TODO + // message. + String getWeatherMessageText = String + .format("TODO: get weather for here (lat: %s, long: %s)", location.getLatitude(), location.getLongitude()); + Activity getWeatherMessage = + MessageFactory.text(getWeatherMessageText, getWeatherMessageText, InputHints.IGNORING_INPUT); + return stepContext.getContext().sendActivity(getWeatherMessage).thenCompose(result -> { + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.COMPLETE)); + }); + + } + + private CompletableFuture beginBookFlight(WaterfallStepContext stepContext) { + Activity activity = stepContext.getContext().getActivity(); + BookingDetails bookingDetails = new BookingDetails(); + if (activity.getValue() != null) { + try { + bookingDetails = Serialization.safeGetAs(activity.getValue(), BookingDetails.class); + } catch (JsonProcessingException e) { + // we already initialized bookingDetails above, so the flow will run as if + // no details were sent. + } + } + + // Start the booking dialog. + Dialog bookingDialog = findDialog("BookingDialog"); + return stepContext.beginDialog(bookingDialog.getId(), bookingDetails); + } + + private String GetObjectAsJsonString(Object value) { + try { + return new JacksonAdapter().serialize(value); + } catch (IOException e) { + return null; + } + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDetails.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDetails.java new file mode 100644 index 000000000..549c6dac0 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDetails.java @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class BookingDetails { + + @JsonProperty(value = "destination") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String destination; + + @JsonProperty(value = "origin") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String origin; + + @JsonProperty(value = "travelDate") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String travelDate; + + /** + * @return the Destination value as a String. + */ + public String getDestination() { + return this.destination; + } + + /** + * @param withDestination The Destination value. + */ + public void setDestination(String withDestination) { + this.destination = withDestination; + } + + /** + * @return the Origin value as a String. + */ + public String getOrigin() { + return this.origin; + } + + /** + * @param withOrigin The Origin value. + */ + public void setOrigin(String withOrigin) { + this.origin = withOrigin; + } + + /** + * @return the TravelDate value as a String. + */ + public String getTravelDate() { + return this.travelDate; + } + + /** + * @param withTravelDate The TravelDate value. + */ + public void setTravelDate(String withTravelDate) { + this.travelDate = withTravelDate; + } + +} + diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDialog.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDialog.java new file mode 100644 index 000000000..29cbc0fd4 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/BookingDialog.java @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStep; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.dialogs.prompts.ConfirmPrompt; +import com.microsoft.bot.dialogs.prompts.PromptOptions; +import com.microsoft.bot.dialogs.prompts.TextPrompt; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.recognizers.datatypes.timex.expression.Constants; +import com.microsoft.recognizers.datatypes.timex.expression.TimexProperty; + +public class BookingDialog extends CancelAndHelpDialog { + + private final String DestinationStepMsgText = "Where would you like to travel to?"; + private final String OriginStepMsgText = "Where are you traveling from?"; + + public BookingDialog() { + super("BookingDialog"); + addDialog(new TextPrompt("TextPrompt")); + addDialog(new ConfirmPrompt("ConfirmPrompt")); + addDialog(new DateResolverDialog(null)); + WaterfallStep[] waterfallSteps = { this::destinationStep, this::originStep, this::travelDateStep, + this::confirmStep, this::finalStep }; + addDialog(new WaterfallDialog("WaterfallDialog", Arrays.asList(waterfallSteps))); + + // The initial child Dialog to run. + setInitialDialogId("WaterfallDialog"); + } + + private static boolean IsAmbiguous(String timex) { + TimexProperty timexProperty = new TimexProperty(timex); + return !timexProperty.getTypes().contains(Constants.TimexTypes.DEFINITE); + } + + private CompletableFuture destinationStep(WaterfallStepContext stepContext) { + BookingDetails bookingDetails = (BookingDetails) stepContext.getOptions(); + + if (bookingDetails.getDestination() == null) { + Activity promptMessage = MessageFactory.text(DestinationStepMsgText, DestinationStepMsgText, + InputHints.EXPECTING_INPUT); + + PromptOptions options = new PromptOptions(); + options.setPrompt(promptMessage); + return stepContext.prompt("TextPrompt", options); + } + + return stepContext.next(bookingDetails.getDestination()); + } + + private CompletableFuture originStep(WaterfallStepContext stepContext) { + BookingDetails bookingDetails = (BookingDetails) stepContext.getOptions(); + + bookingDetails.setDestination((String) stepContext.getResult()); + + if (bookingDetails.getOrigin() == null) { + Activity promptMessage = MessageFactory.text(OriginStepMsgText, OriginStepMsgText, + InputHints.EXPECTING_INPUT); + PromptOptions options = new PromptOptions(); + options.setPrompt(promptMessage); + return stepContext.prompt("TextPrompt", options); + } + + return stepContext.next(bookingDetails.getOrigin()); + } + + private CompletableFuture travelDateStep(WaterfallStepContext stepContext) { + BookingDetails bookingDetails = (BookingDetails) stepContext.getOptions(); + + bookingDetails.setOrigin((String) stepContext.getResult()); + + if (bookingDetails.getTravelDate() == null || IsAmbiguous(bookingDetails.getTravelDate())) { + return stepContext.beginDialog("DateResolverDialog", bookingDetails.getTravelDate()); + } + + return stepContext.next(bookingDetails.getTravelDate()); + } + + private CompletableFuture confirmStep(WaterfallStepContext stepContext) { + BookingDetails bookingDetails = (BookingDetails) stepContext.getOptions(); + + bookingDetails.setTravelDate((String) stepContext.getResult()); + + String messageText = String.format( + "Please confirm, I have you traveling to: %s from: %s on: %s. Is this correct?", + bookingDetails.getDestination(), bookingDetails.getOrigin(), bookingDetails.getTravelDate()); + Activity promptMessage = MessageFactory.text(messageText, messageText, InputHints.EXPECTING_INPUT); + + PromptOptions options = new PromptOptions(); + options.setPrompt(promptMessage); + return stepContext.prompt("ConfirmPrompt", options); + } + + private CompletableFuture finalStep(WaterfallStepContext stepContext) { + if ((Boolean) stepContext.getResult()) { + BookingDetails bookingDetails = (BookingDetails) stepContext.getOptions(); + + return stepContext.endDialog(bookingDetails); + } + + return stepContext.endDialog(null); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/CancelAndHelpDialog.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/CancelAndHelpDialog.java new file mode 100644 index 000000000..c7566bbdc --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/CancelAndHelpDialog.java @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.ComponentDialog; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.DialogTurnStatus; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.InputHints; + +public class CancelAndHelpDialog extends ComponentDialog { + + private final String HelpMsgText = "Show help here"; + private final String CancelMsgText = "Canceling..."; + + public CancelAndHelpDialog(String id) { + super(id); + } + + @Override + protected CompletableFuture onContinueDialog(DialogContext innerDc) { + return interrupt(innerDc).thenCompose(result -> { + if (result != null && result instanceof DialogTurnResult) { + return CompletableFuture.completedFuture((DialogTurnResult) result); + } + return super.onContinueDialog(innerDc); + }); + } + + private CompletableFuture interrupt(DialogContext innerDc) { + if (innerDc.getContext().getActivity().getType().equals(ActivityTypes.MESSAGE)) { + String text = innerDc.getContext().getActivity().getText().toLowerCase(); + + switch (text) { + case "help": + case "?": + Activity helpMessage = MessageFactory.text(HelpMsgText, HelpMsgText, InputHints.EXPECTING_INPUT); + return innerDc.getContext() + .sendActivity(helpMessage) + .thenCompose( + result -> CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.WAITING)) + ); + + case "cancel": + case "quit": + Activity cancelMessage = + MessageFactory.text(CancelMsgText, CancelMsgText, InputHints.IGNORING_INPUT); + return innerDc.getContext().sendActivity(cancelMessage).thenCompose(result -> { + return innerDc.cancelAllDialogs(); + }); + + } + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DateResolverDialog.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DateResolverDialog.java new file mode 100644 index 000000000..9ead84b08 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DateResolverDialog.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.microsoft.applicationinsights.core.dependencies.apachecommons.lang3.StringUtils; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStep; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.dialogs.prompts.DateTimePrompt; +import com.microsoft.bot.dialogs.prompts.DateTimeResolution; +import com.microsoft.bot.dialogs.prompts.PromptOptions; +import com.microsoft.bot.dialogs.prompts.PromptValidator; +import com.microsoft.bot.dialogs.prompts.PromptValidatorContext; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.recognizers.datatypes.timex.expression.Constants; +import com.microsoft.recognizers.datatypes.timex.expression.TimexProperty; + +public class DateResolverDialog extends CancelAndHelpDialog { + + private final String PromptMsgText = "When would you like to travel?"; + private final String RepromptMsgText = "I'm sorry, to make your booking please enter a full travel date, including Day, Month, and Year."; + + public DateResolverDialog(String id) { + super(!StringUtils.isAllBlank(id) ? id : "DateResolverDialog"); + addDialog(new DateTimePrompt("DateTimePrompt", new dateTimePromptValidator(), null)); + WaterfallStep[] waterfallSteps = { this::initialStep, this::finalStep }; + addDialog(new WaterfallDialog("WaterfallDialog", Arrays.asList(waterfallSteps))); + + // The initial child Dialog to run. + setInitialDialogId("WaterfallDialog"); + } + + class dateTimePromptValidator implements PromptValidator> { + + @Override + public CompletableFuture promptValidator( + PromptValidatorContext> promptContext) { + if (promptContext.getRecognized().getSucceeded()) { + // This value will be a TMEX. We are only interested in the Date part, so grab + // the first result and drop the Time part. + // TMEX instanceof a format that represents DateTime expressions that include + // some ambiguity, such as a missing Year. + String timex = promptContext.getRecognized().getValue().get(0).getTimex().split("T")[0]; + + // If this instanceof a definite Date that includes year, month and day we are + // good; otherwise, reprompt. + // A better solution might be to let the user know what part instanceof actually + // missing. + Boolean isDefinite = new TimexProperty(timex).getTypes().contains(Constants.TimexTypes.DEFINITE); + + return CompletableFuture.completedFuture(isDefinite); + } + return CompletableFuture.completedFuture(false); + } + + } + + public CompletableFuture initialStep(WaterfallStepContext stepContext) { + String timex = (String) stepContext.getOptions(); + + Activity promptMessage = MessageFactory.text(PromptMsgText, PromptMsgText, InputHints.EXPECTING_INPUT); + Activity repromptMessage = MessageFactory.text(RepromptMsgText, RepromptMsgText, InputHints.EXPECTING_INPUT); + + if (timex == null) { + // We were not given any date at all so prompt the user. + PromptOptions options = new PromptOptions(); + options.setPrompt(promptMessage); + options.setRetryPrompt(repromptMessage); + return stepContext.prompt("DateTimePrompt", options); + } + + // We have a Date we just need to check it instanceof unambiguous. + TimexProperty timexProperty = new TimexProperty(timex); + if (!timexProperty.getTypes().contains(Constants.TimexTypes.DEFINITE)) { + // This instanceof essentially a "reprompt" of the data we were given up front. + PromptOptions options = new PromptOptions(); + options.setPrompt(repromptMessage); + return stepContext.prompt("DateTimePrompt", options); + } + List resolutionList = new ArrayList(); + DateTimeResolution resolution = new DateTimeResolution(); + resolution.setTimex(timex); + resolutionList.add(resolution); + return stepContext.next(resolutionList); + } + + private CompletableFuture finalStep(WaterfallStepContext stepContext) { + String timex = ((List) stepContext.getResult()).get(0).getTimex(); + return stepContext.endDialog(timex); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DialogSkillBotRecognizer.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DialogSkillBotRecognizer.java new file mode 100644 index 000000000..b1b3d73be --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/DialogSkillBotRecognizer.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import java.util.concurrent.CompletableFuture; + +import com.microsoft.bot.ai.luis.LuisApplication; +import com.microsoft.bot.ai.luis.LuisRecognizer; +import com.microsoft.bot.ai.luis.LuisRecognizerOptionsV3; +import com.microsoft.bot.builder.Recognizer; +import com.microsoft.bot.builder.RecognizerConvert; +import com.microsoft.bot.builder.RecognizerResult; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.integration.Configuration; + +import org.apache.commons.lang3.StringUtils; + +public class DialogSkillBotRecognizer implements Recognizer { + + private final LuisRecognizer _recognizer; + public DialogSkillBotRecognizer(Configuration configuration) { + boolean luisIsConfigured = !StringUtils.isAllBlank(configuration.getProperty("LuisAppId")) + && !StringUtils.isAllBlank(configuration.getProperty("LuisAPIKey")) + && !StringUtils.isAllBlank(configuration.getProperty("LuisAPIHostName")); + if (luisIsConfigured) { + LuisApplication luisApplication = new LuisApplication( + configuration.getProperty("LuisAppId"), + configuration.getProperty("LuisAPIKey"), + String.format("https://%s", configuration.getProperty("LuisAPIHostName"))); + LuisRecognizerOptionsV3 luisOptions = new LuisRecognizerOptionsV3(luisApplication); + + _recognizer = new LuisRecognizer(luisOptions); + } else { + _recognizer = null; + } + } + + // Returns true if LUS instanceof configured in the appsettings.json and initialized. + public boolean getIsConfigured() { + return _recognizer != null; + } + + public CompletableFuture recognize(TurnContext turnContext) { + return _recognizer.recognize(turnContext); + } + + public CompletableFuture recognize( + TurnContext turnContext, + Class c + ) { + return _recognizer.recognize(turnContext, c); + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/Location.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/Location.java new file mode 100644 index 000000000..b668540fb --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/java/com/microsoft/bot/sample/dialogskillbot/dialogs/Location.java @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MT License. + +package com.microsoft.bot.sample.dialogskillbot.dialogs; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Location { + + @JsonProperty(value = "latitude") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Double latitude; + + @JsonProperty(value = "longitude") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Double longitude; + + @JsonProperty(value = "postalCode") + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String postalCode; + + /** + * @return the Latitude value as a double?. + */ + public Double getLatitude() { + return this.latitude; + } + + /** + * @param withLatitude The Latitude value. + */ + public void setLatitude(Double withLatitude) { + this.latitude = withLatitude; + } + + /** + * @return the Longitude value as a double?. + */ + public Double getLongitude() { + return this.longitude; + } + + /** + * @param withLongitude The Longitude value. + */ + public void setLongitude(Double withLongitude) { + this.longitude = withLongitude; + } + + /** + * @return the PostalCode value as a String. + */ + public String getPostalCode() { + return this.postalCode; + } + + /** + * @param withPostalCode The PostalCode value. + */ + public void setPostalCode(String withPostalCode) { + this.postalCode = withPostalCode; + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/application.properties b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/application.properties new file mode 100644 index 000000000..f86b40a07 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/application.properties @@ -0,0 +1,13 @@ +MicrosoftAppId= +MicrosoftAppPassword= +server.port=39783 + +LuisAppId= +LuisAPIKey= +LuisAPIHostName= +# This is a comma separate list with the App IDs that will have access to the skill. +# This setting is used in AllowedCallersClaimsValidator. +# Examples: +# * allows all callers. +# AppId1,AppId2 only allows access to parent bots with "AppId1" and "AppId2". +AllowedCallers=* diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/log4j2.json b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/log4j2.json new file mode 100644 index 000000000..67c0ad530 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/resources/log4j2.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "name": "Default", + "appenders": { + "Console": { + "name": "Console-Appender", + "target": "SYSTEM_OUT", + "PatternLayout": {"pattern": "[%-5level] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %c{1} - %msg%n"} + } + }, + "loggers": { + "root": { + "level": "debug", + "appender-ref": {"ref": "Console-Appender","level": "debug"} + } + } + } +} diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/META-INF/MANIFEST.MF b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 000000000..254272e1c --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: + diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/WEB-INF/web.xml b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..383c19004 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,12 @@ + + + dispatcher + + org.springframework.web.servlet.DispatcherServlet + + + contextConfigLocation + /WEB-INF/spring/dispatcher-config.xml + + 1 + \ No newline at end of file diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/index.html b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/index.html new file mode 100644 index 000000000..d5ba5158e --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/index.html @@ -0,0 +1,418 @@ + + + + + + + EchoBot + + + + + +
+
+
+
Spring Boot Bot
+
+
+
+
+
Your bot is ready!
+
You can test your bot in the Bot Framework Emulator
+ by connecting to http://localhost:3978/api/messages.
+ +
Visit Azure + Bot Service to register your bot and add it to
+ various channels. The bot's endpoint URL typically looks + like this:
+
https://your_bots_hostname/api/messages
+
+
+
+
+ +
+ + + diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json new file mode 100644 index 000000000..924b68e6f --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/main/webapp/manifest/echoskillbot-manifest-1.0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.0.0.json", + "$id": "EchoSkillBot", + "name": "Echo Skill bot", + "version": "1.0", + "description": "This is a sample echo skill", + "publisherName": "Microsoft", + "privacyUrl": "https://echoskillbot.contoso.com/privacy.html", + "copyright": "Copyright (c) Microsoft Corporation. All rights reserved.", + "license": "", + "iconUrl": "https://echoskillbot.contoso.com/icon.png", + "tags": [ + "sample", + "echo" + ], + "endpoints": [ + { + "name": "default", + "protocol": "BotFrameworkV3", + "description": "Default endpoint for the skill", + "endpointUrl": "http://echoskillbot.contoso.com/api/messages", + "msAppId": "00000000-0000-0000-0000-000000000000" + } + ] + } \ No newline at end of file diff --git a/samples/81.skills-skilldialog/dialog-skill-bot/src/test/java/com/microsoft/bot/sample/dialogskillbot/ApplicationTest.java b/samples/81.skills-skilldialog/dialog-skill-bot/src/test/java/com/microsoft/bot/sample/dialogskillbot/ApplicationTest.java new file mode 100644 index 000000000..a211ca163 --- /dev/null +++ b/samples/81.skills-skilldialog/dialog-skill-bot/src/test/java/com/microsoft/bot/sample/dialogskillbot/ApplicationTest.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.sample.dialogskillbot; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class ApplicationTest { + + @Test + public void contextLoads() { + } + +}