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