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

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’.

image

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