Create Bot for Microsoft Graph with DevOps 16: BotBuilder features - using Luis Entities with FormFlow

07/31/2017

12 minutes to read

In this article

In this article, I will utilize Entity from LUIS and use the result to FormFlow.

Use LUIS Entities

1. Change the code in CreateEventDialog.cs to use Entities from LuisResult. I just parsed several types for datatimeV2. See more detail about datetimeV2 here. You can parse the result anywhere, but I am doing so in StartAsync. If entities include date time, compare Start date time to now and decide if I need to ask user for Start date again.

using Autofac; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.FormFlow; using Microsoft.Bot.Builder.Luis.Models; using Microsoft.Graph; using Newtonsoft.Json.Linq; using O365Bot.Models; using O365Bot.Services; using System; using System.Threading.Tasks; namespace O365Bot.Dialogs { [Serializable] public class CreateEventDialog : IDialog<bool> { LuisResult luisResult; public CreateEventDialog(LuisResult luisResult) { this.luisResult = luisResult; } public async Task StartAsync(IDialogContext context) { var @event = new OutlookEvent(); // Use Entities value from LuisResult foreach (EntityRecommendation entity in luisResult.Entities) { switch (entity.Type) { case "Calendar.Subject": @event.Subject = entity.Entity; break; case "builtin.datetimeV2.datetime": foreach (var vals in entity.Resolution.Values) { switch (((JArray)vals).First.SelectToken("type").ToString()) { case "daterange": var start = (DateTime)((JArray)vals).First["start"]; var end = (DateTime)((JArray)vals).First["end"]; @event.Start = start; @event.Hours = end.Hour - start.Hour; break; case "datetime": @event.Start = (DateTime)((JArray)vals).First["value"]; break; } } break; } } @event.Description = luisResult.Query; // Pass the instance to FormFlow var outlookEventFormDialog = new FormDialog<OutlookEvent>(@event, BuildOutlookEventForm, FormOptions.PromptInStart); context.Call(outlookEventFormDialog, this.ResumeAfterDialog); } private async Task ResumeAfterDialog(IDialogContext context, IAwaitable<OutlookEvent> result) { await context.PostAsync("The event is created."); // Complete the child dialog. context.Done(true); } private IForm<OutlookEvent> BuildOutlookEventForm() { OnCompletionAsyncDelegate<OutlookEvent> processOutlookEventCreate = async (context, state) => { using (var scope = WebApiApplication.Container.BeginLifetimeScope()) { IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context)); Event @event = new Event() { Subject = state.Subject, Start = new DateTimeTimeZone() { DateTime = state.Start.ToString(), TimeZone = "Tokyo Standard Time" }, IsAllDay = state.IsAllDay, End = state.IsAllDay ? null : new DateTimeTimeZone() { DateTime = state.Start.AddHours(state.Hours).ToString(), TimeZone = "Tokyo Standard Time" }, Body = new ItemBody() { Content = state.Description, ContentType = BodyType.Text } }; await service.CreateEvent(@event); } }; return new FormBuilder<OutlookEvent>() .Message("Creating an event.") .Field(nameof(OutlookEvent.Subject), prompt: "What is the title?", validate: async (state, value) => { var subject = (string)value; var result = new ValidateResult() { IsValid = true, Value = subject }; if (subject.Contains("FormFlow")) { result.IsValid = false; result.Feedback = "You cannot include FormFlow as subject."; } return result; }) .Field(nameof(OutlookEvent.Description), prompt: "What is the detail?") .Field(nameof(OutlookEvent.Start), prompt: "When do you start? Use dd/MM/yyyy HH:mm format.", active:(state)=> { // If this is all day event, then do not display hours field. if (state.Start < DateTime.Now.Date) return true; else return false; }) .Field(nameof(OutlookEvent.IsAllDay), prompt: "Is this all day event?{||}") .Field(nameof(OutlookEvent.Hours), prompt: "How many hours?", active: (state) => { // If this is all day event, then do not display hours field. if (state.IsAllDay) return false; else return true; }) .OnCompletion(processOutlookEventCreate) .Build(); } } }

2. In LuisRootDialog.cs, store LuisResult before calling Auth dialog and pass LuisResult for CreateEventDialog.

using AuthBot; using AuthBot.Dialogs; using Autofac; using Microsoft.Bot.Builder.ConnectorEx; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Luis; using Microsoft.Bot.Builder.Luis.Models; using Microsoft.Bot.Connector; using O365Bot.Services; using System; using System.Configuration; using System.Threading; using System.Threading.Tasks; namespace O365Bot.Dialogs { [LuisModel("LUIS APP ID", "SUBSCRIPTION KEY")] [Serializable] public class LuisRootDialog : LuisDialog<object> { LuisResult luisResult; [LuisIntent("Calendar.Find")] public async Task GetEvents(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result) { this.luisResult = result; var message = await activity; // Check authentication if (string.IsNullOrEmpty(await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]))) { // Store luisDialog luisResult = result; await Authenticate(context, message); } else { await SubscribeEventChange(context, message); await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None); } } [LuisIntent("Calendar.Add")] public async Task CreateEvent(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result) { var message = await activity; // Check authentication if (string.IsNullOrEmpty(await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]))) { // Store luisDialog luisResult = result; await Authenticate(context, message); } else { await SubscribeEventChange(context, message); context.Call(new CreateEventDialog(result), ResumeAfterDialog); } } [LuisIntent("None")] public async Task NoneHandler(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result) { await context.PostAsync("Cannot understand"); } private async Task Authenticate(IDialogContext context, IMessageActivity message) { // Store the original message. context.PrivateConversationData.SetValue<Activity>("OriginalMessage", message as Activity); // Run authentication dialog. await context.Forward(new AzureAuthDialog(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]), this.ResumeAfterAuth, message, CancellationToken.None); } private async Task SubscribeEventChange(IDialogContext context, IMessageActivity message) { if (message.ChannelId != "emulator" && message.ChannelId != "directline") { using (var scope = WebApiApplication.Container.BeginLifetimeScope()) { var service = scope.Resolve<INotificationService>(new TypedParameter(typeof(IDialogContext), context)); // Subscribe to Office 365 event change var subscriptionId = context.UserData.GetValueOrDefault<string>("SubscriptionId", ""); if (string.IsNullOrEmpty(subscriptionId)) { subscriptionId = await service.SubscribeEventChange(); context.UserData.SetValue("SubscriptionId", subscriptionId); } else await service.RenewSubscribeEventChange(subscriptionId); // Convert current message as ConversationReference. var conversationReference = message.ToConversationReference(); // Map the ConversationReference to SubscriptionId of Microsoft Graph Notification. if (CacheService.caches.ContainsKey(subscriptionId)) CacheService.caches[subscriptionId] = conversationReference; else CacheService.caches.Add(subscriptionId, conversationReference); // Store locale info as conversation info doesn't store it. if (!CacheService.caches.ContainsKey(message.From.Id)) CacheService.caches.Add(message.From.Id, Thread.CurrentThread.CurrentCulture.Name); } } } private async Task ResumeAfterDialog(IDialogContext context, IAwaitable<bool> result) { // Get the dialog result var dialogResult = await result; context.Done(true); } private async Task ResumeAfterAuth(IDialogContext context, IAwaitable<string> result) { // Restore the original message. var message = context.PrivateConversationData.GetValue<Activity>("OriginalMessage"); await SubscribeEventChange(context, message); switch (luisResult.TopScoringIntent.Intent) { case "Calendar.Find": await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None); break; case "Calendar.Add": context.Call(new CreateEventDialog(luisResult), ResumeAfterDialog); break; case "None": await context.PostAsync("Cannot understand"); break; } } } }

Test with the emulator

1. Run the project by pressing F5.

2. Connect and send ‘eat dinner with my wife at outback stakehouse at shinagawa at 7 pm next Wednesday’.

Update Unit Tests

There are several considerations when doing unit testing for this. In this case, I mocked LUIS service and returned multiple EntityRecommendation. You shall change it depending on how you test it.

1. Replace LuisUnitTest1.cs with following code. Creating datetimeV2 entity results is a bit troublesome.

using Autofac; using Microsoft.Bot.Builder.Base; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Internals; using Microsoft.Bot.Builder.Internals.Fibers; using Microsoft.Bot.Builder.Luis; using Microsoft.Bot.Builder.Luis.Models; using Microsoft.Bot.Builder.Tests; using Microsoft.Bot.Connector; using Microsoft.Graph; using Microsoft.QualityTools.Testing.Fakes; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Newtonsoft.Json.Linq; using O365Bot.Dialogs; using O365Bot.Services; using System; using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; namespace O365Bot.UnitTests { [TestClass] public class SampleLuisTest : LuisTestBase { [TestMethod] public async Task ShouldReturnEvents() { // Instantiate ShimsContext to use Fakes using (ShimsContext.Create()) { // Return "dummyToken" when calling GetAccessToken method AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // Mock the LUIS service var luis1 = new Mock<ILuisService>(); // Mock other services var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.GetEvents()).ReturnsAsync(new List<Event>() { new Event { Subject = "dummy event", Start = new DateTimeTimeZone() { DateTime = "2017-05-31 12:00", TimeZone = "Standard Tokyo Time" }, End = new DateTimeTimeZone() { DateTime = "2017-05-31 13:00", TimeZone = "Standard Tokyo Time" } } }); var subscriptionId = Guid.NewGuid().ToString(); var mockNotificationService = new Mock<INotificationService>(); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); builder.RegisterInstance(mockNotificationService.Object).As<INotificationService>(); WebApiApplication.Container = builder.Build(); /// Instantiate dialog to test LuisRootDialog rootDialog = new LuisRootDialog(); // Create in-memory bot environment Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As<IDialog<object>>(); dialogBuilder.Update(container); // Register global message handler RegisterBotModules(container); // Specify "Calendar.Find" intent as LUIS result SetupLuis<LuisRootDialog>(luis1, d => d.GetEvents(null, null, null), 1.0, new EntityRecommendation(type: "Calendar.Find")); // Create a message to send to bot var toBot = DialogTestBase.MakeTestMessage(); toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "get events"; // Send message and check the answer. IMessageActivity toUser = await GetResponse(container, MakeRoot, toBot); // Verify the result Assert.IsTrue(toUser.Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event")); } } } [TestMethod] public async Task ShouldCreateAllDayEvent() { // Instantiate ShimsContext to use Fakes using (ShimsContext.Create()) { // Return "dummyToken" when calling GetAccessToken method AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // Mock the LUIS service var luis1 = new Mock<ILuisService>(); // Mock other services var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); var subscriptionId = Guid.NewGuid().ToString(); var mockNotificationService = new Mock<INotificationService>(); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); builder.RegisterInstance(mockNotificationService.Object).As<INotificationService>(); WebApiApplication.Container = builder.Build(); /// Instantiate dialog to test LuisRootDialog rootDialog = new LuisRootDialog(); // Create in-memory bot environment Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As<IDialog<object>>(); dialogBuilder.Update(container); // Register global message handler RegisterBotModules(container); // create datetimeV2 resolution Dictionary<string, object> resolution = new Dictionary<string, object>(); JArray values = new JArray(); Dictionary<string, object> resolutionData = new Dictionary<string, object>(); resolutionData.Add("type", "datetime"); resolutionData.Add("value", DateTime.Now.AddDays(1)); values.Add(JToken.FromObject(resolutionData)); resolution.Add("values", values); // Specify "Calendar.Find" intent as LUIS result SetupLuis<LuisRootDialog>(luis1, d => d.GetEvents(null, null, null), 1.0, new EntityRecommendation(type: "Calendar.Subject", entity: "dummy subject"), new EntityRecommendation(type: "builtin.datetimeV2.datetime", resolution: resolution)); // Create a message to send to bot var toBot = DialogTestBase.MakeTestMessage(); toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "eat dinner with my wife at outback stakehouse at shinagawa at 7 pm next Wednesday"; // Send message and check the answer. var toUser = await GetResponses(container, MakeRoot, toBot); // Verify the result Assert.IsTrue(toUser[0].Text.Equals("Creating an event.")); Assert.IsTrue((toUser[1].Attachments[0].Content as HeroCard).Text.Equals("Is this all day event?")); toBot.Text = "Yes"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals("The event is created.")); } } } [TestMethod] public async Task ShouldCreateEvent() { // Instantiate ShimsContext to use Fakes using (ShimsContext.Create()) { // Return "dummyToken" when calling GetAccessToken method AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // Mock the LUIS service var luis1 = new Mock<ILuisService>(); // Mock other services var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); var subscriptionId = Guid.NewGuid().ToString(); var mockNotificationService = new Mock<INotificationService>(); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); builder.RegisterInstance(mockNotificationService.Object).As<INotificationService>(); WebApiApplication.Container = builder.Build(); /// Instantiate dialog to test LuisRootDialog rootDialog = new LuisRootDialog(); // Create in-memory bot environment Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As<IDialog<object>>(); dialogBuilder.Update(container); // Register global message handler RegisterBotModules(container); // create datetimeV2 resolution Dictionary<string, object> resolution = new Dictionary<string, object>(); JArray values = new JArray(); Dictionary<string, object> resolutionData = new Dictionary<string, object>(); resolutionData.Add("type", "datetime"); resolutionData.Add("value", DateTime.Now.AddDays(1)); values.Add(JToken.FromObject(resolutionData)); resolution.Add("values", values); // Specify "Calendar.Find" intent as LUIS result SetupLuis<LuisRootDialog>(luis1, d => d.GetEvents(null, null, null), 1.0, new EntityRecommendation(type: "Calendar.Subject", entity: "dummy subject"), new EntityRecommendation(type: "builtin.datetimeV2.datetime", resolution: resolution)); // Create a message to send to bot var toBot = DialogTestBase.MakeTestMessage(); toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "eat dinner with my wife at outback stakehouse at shinagawa at 7 pm next Wednesday"; // Send message and check the answer. var toUser = await GetResponses(container, MakeRoot, toBot); // Verify the result Assert.IsTrue(toUser[0].Text.Equals("Creating an event.")); Assert.IsTrue((toUser[1].Attachments[0].Content as HeroCard).Text.Equals("Is this all day event?")); toBot.Text = "No"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals("How many hours?")); toBot.Text = "3"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals("The event is created.")); } } } /// <summary> /// Send a message to the bot and get repsponse. /// </summary> public async Task<IMessageActivity> GetResponse(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { DialogModule_MakeRoot.Register(scope, makeRoot); // act: sending the message using (new LocalizedScope(toBot.Locale)) { var task = scope.Resolve<IPostToBot>(); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); return scope.Resolve<Queue<IMessageActivity>>().Dequeue(); } } /// <summary> /// Send a message to the bot and get all repsponses. /// </summary> public async Task<List<IMessageActivity>> GetResponses(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { var results = new List<IMessageActivity>(); DialogModule_MakeRoot.Register(scope, makeRoot); // act: sending the message using (new LocalizedScope(toBot.Locale)) { var task = scope.Resolve<IPostToBot>(); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); var queue = scope.Resolve<Queue<IMessageActivity>>(); while (queue.Count != 0) { results.Add(queue.Dequeue()); } return results; } } /// <summary> /// Register Global Message /// </summary> private void RegisterBotModules(IContainer container) { var builder = new ContainerBuilder(); builder.RegisterModule(new ReflectionSurrogateModule()); builder.RegisterModule<GlobalMessageHandlers>(); builder.RegisterType<ActivityLogger>().AsImplementedInterfaces().InstancePerDependency(); builder.Update(container); } /// <summary> /// Resume the conversation /// </summary> public async Task<List<IMessageActivity>> Resume(IContainer container, IDialog<object> dialog, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { var results = new List<IMessageActivity>(); var botData = scope.Resolve<IBotData>(); await botData.LoadAsync(CancellationToken.None); var task = scope.Resolve<IDialogTask>(); // Insert dialog to current event task.Call(dialog.Void<object, IMessageActivity>(), null); await task.PollAsync(CancellationToken.None); await botData.FlushAsync(CancellationToken.None); // Get the result var queue = scope.Resolve<Queue<IMessageActivity>>(); while (queue.Count != 0) { results.Add(queue.Dequeue()); } return results; } } } }

2. Compile the solution and run all unit tests.

Function test shall remains same. Check in all the code and confirm CI/CD passed as expected.

Summery

Using LUIS is a key to make the bot intelligent to understand user Intent and Entities. Especially datetimeV2 is super powerful.

GitHub: https://github.com/kenakamu/BotWithDevOps-Blog-sample/tree/master/article16

Ken