Create Bot for Microsoft Graph with DevOps 8: Dialog 201 - Make conversation easier with DialogPrompt

In this article, I use DialogPrompt and add create events feature to O365Bot

DialogPrompt

As a developer, you need to consider the following things.

  • Reply content: Just text string or rich contents such as buttons and/or images.
  • Input validation: If a user send valid data, such as date time or integer as developer expects.
  • Retry: In case of failure, bot should retry the attempt to get input.
  • Catch: When retries didn’t work, then you cannot keep asking to the use the same time forever.

Well, there are way more things to consider, but DialogPrompt handles most of them for you. It evens understand locale. I will explain multi-language support in the future.

Add create events feature

So far, the O365Bot only gets events from your outlook. Let’s implement new feature to add an event.

Update Graph Service

1. Add CreateEvent method signature to IEventService interface.

 public interface IEventService
{
    Task<List<Event>> GetEvents();
    Task CreateEvent(Event @event);
}

2. Implement the actual code in GraphService.cs

 public async Task CreateEvent(Event @event)
{
    var client = await GetClient();

    try
    {
        var events = await client.Me.Events.Request().AddAsync(@event);
    }
    catch (Exception ex)
    {
    }
}

Add CreateEventDialog.cs

Like GetEventsDialog, I will implement the event creation feature inside a child dialog. Add CreateEventDialog.cs in Dialogs folder and replace the code. I use several different types of DialogPrompts, but basically they are all same. Ask for input, validate, specify retry, etc.

 using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Graph;
using O365Bot.Services;
using System;
using System.Globalization;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class CreateEventDialog : IDialog<bool>
    {
        private string subject;
        private string detail;
        private DateTime start;
        private bool isAllDay;
        private double hours;

        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync("Creating an event.");
            // Ask for text input
            PromptDialog.Text(context, ResumeAfterTitle, "What is the title?");
        }

        private async Task ResumeAfterTitle(IDialogContext context, IAwaitable<string> result)
        {
            subject = await result;
            // Ask for text input
            PromptDialog.Text(context, ResumeAfterDetail, "What is the detail?");
        }

        private async Task ResumeAfterDetail(IDialogContext context, IAwaitable<string> result)
        {
            detail = await result;
            // As DialogPrompt cannot ask for datetime, ask for text input instead.
            PromptDialog.Text(context, ResumeAfterStard, "When do you start? Use dd/MM/yyyy HH:mm format.");
        }

        private async Task ResumeAfterStard(IDialogContext context, IAwaitable<string> result)
        {
            // Verify the input, and retry if failed.
            if (!DateTime.TryParseExact(await result, "dd/MM/yyyy HH:mm", CultureInfo.CurrentCulture, DateTimeStyles.None, out start))
            {
                PromptDialog.Text(context, ResumeAfterStard, "Wrong format. Use dd/MM/yyyy HH:mm format.");
            }
            // Ask for confirmation. If input validation fails, retry up to 3 times.
            PromptDialog.Confirm(context, ResumeAfterIsAllDay, "Is this all day event?", "Please select the choice.");
        }

        private async Task ResumeAfterIsAllDay(IDialogContext context, IAwaitable<bool> result)
        {
            isAllDay = await result;
            if (isAllDay)
                await CreateEvent(context);
            else
                // Ask for number.
                PromptDialog.Number(context, ResumeAfterHours, "How many hours?", "Please answer by number");
        }

        private async Task ResumeAfterHours(IDialogContext context, IAwaitable<long> result)
        {
            hours = await result;
            await CreateEvent(context);
        }

        private async Task CreateEvent(IDialogContext context)
        {
            using (var scope = WebApiApplication.Container.BeginLifetimeScope())
            {
                IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context));
                // We can get TimeZone by using https://graph.microsoft.com/beta/me/mailboxSettings, but just hard-coding here for test purpose.
                Event @event = new Event()
                {
                    Subject = subject,
                    Start = new DateTimeTimeZone() { DateTime = start.ToString(), TimeZone = "Tokyo Standard Time" },
                    IsAllDay = isAllDay,
                    End = isAllDay ? null : new DateTimeTimeZone() { DateTime = start.AddHours(hours).ToString(), TimeZone = "Tokyo Standard Time" },
                    Body = new ItemBody() { Content = detail, ContentType = BodyType.Text }
                };
                await service.CreateEvent(@event);
                await context.PostAsync("The event is created.");
            }

            // Complete the child dialog.
            context.Done(true);
        }
    }
}

Update RouteDialog.cs

Now I have two dialogs, so RootDialog should handle them.

1. Replace the DoWork method with following code. I use “Call” method to chain the child dialog this time, as “Call” method doesn’t pass the user input, which I don’t need this time. If I use “Forward” method, then the initial user input is considered to be the title of the event.

 private async Task DoWork(IDialogContext context, IMessageActivity message)
{
    if (message.Text.Contains("get"))
        // Chain to GetEventDialog
        await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None);
    else if (message.Text.Contains("add"))
        // Chain to CreateEventDialog
        context.Call(new CreateEventDialog(), ResumeAfterDialog);
}

2. I want to re-use the callback method. Replace the method name from ResumeAfterGetEventsDialog to ResumeAfterDialog.

Update Tests

Update Unit Test

1. Add GetResponses method to UnitTest1.cs, by which I can get all the responses from the bot.

 /// <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;
    }
}

2. Add following two unit tests. One for create an event as all day and another for non-all day 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 service and register
        var mockEventService = new Mock<IEventService>();
        mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));

        var builder = new ContainerBuilder();
        builder.RegisterInstance(mockEventService.Object).As<IEventService>();
        WebApiApplication.Container = builder.Build();

        // Instantiate dialog to test
        IDialog<object> rootDialog = new RootDialog();

        // Create in-memory bot environment
        Func<IDialog<object>> MakeRoot = () => rootDialog;
        using (new FiberTestBase.ResolveMoqAssembly(rootDialog))
        using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog))
        {
            // Create a message to send to bot
            var toBot = DialogTestBase.MakeTestMessage();
            // Specify local as US English
            toBot.Locale = "en-US";
            toBot.From.Id = Guid.NewGuid().ToString();
            toBot.Text = "add appointment";

            // 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].Text.Equals("What is the title?"));

            toBot.Text = "Learn BotFramework";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue(toUser[0].Text.Equals("What is the detail?"));

            toBot.Text = "Implement O365Bot";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue(toUser[0].Text.Equals("When do you start? Use dd/MM/yyyy HH:mm format."));

            toBot.Text = "01/07/2017 13:00";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue((toUser[0].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 service and register
        var mockEventService = new Mock<IEventService>();
        mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));
        var builder = new ContainerBuilder();
        builder.RegisterInstance(mockEventService.Object).As<IEventService>();
        WebApiApplication.Container = builder.Build();

        // Instantiate dialog to test
        IDialog<object> rootDialog = new RootDialog();

        // Create in-memory bot environment
        Func<IDialog<object>> MakeRoot = () => rootDialog;
        using (new FiberTestBase.ResolveMoqAssembly(rootDialog))
        using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog))
        {
            // Create a message to send to bot
            var toBot = DialogTestBase.MakeTestMessage();
            // Specify local as US English
            toBot.Locale = "en-US";
            toBot.From.Id = Guid.NewGuid().ToString();
            toBot.Text = "add appointment";

            // 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].Text.Equals("What is the title?"));

            toBot.Text = "Learn BotFramework";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue(toUser[0].Text.Equals("What is the detail?"));

            toBot.Text = "Implement O365Bot";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue(toUser[0].Text.Equals("When do you start? Use dd/MM/yyyy HH:mm format."));

            toBot.Text = "01/07/2017 13:00";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue((toUser[0].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 = "4";
            toUser = await GetResponses(container, MakeRoot, toBot);
            Assert.IsTrue(toUser[0].Text.Equals("The event is created."));
        }
    }
}

3. For ShouldReturnEvents test, replace the initial input from “hi!” to “get events”

4. Compile and run the unit tests.

image

Update Function Test

1. To support locale, update SentMessage method in DirectLineHelper.cs

 public List<Activity> SentMessage(string text, string locale = "en-US")
{
    Activity activity = new Activity()
    {
        Type = ActivityTypes.Message,
        From = new ChannelAccount(userId, userId),
        Text = text,
        Locale = locale
    };
    client.Conversations.PostActivity(conversationId, activity);
    var reply = client.Conversations.GetActivities(conversationId, watermark);

    watermark = reply.Watermark;
    return reply.Activities.Where(x => x.From.Id != userId).ToList();
}

2. Add using statement in FunctionTest1.cs.

 using Newtonsoft.Json;
using Microsoft.Bot.Connector.DirectLine;

3. Add following two tests.

 [TestMethod]
public void Function_ShouldCreateAllDayEvent()
{
    DirectLineHelper helper = new DirectLineHelper(TestContext);
    var toUser = helper.SentMessage("add appointment");

    // Verify the result
    Assert.IsTrue(toUser[0].Text.Equals("Creating an event."));
    Assert.IsTrue(toUser[1].Text.Equals("What is the title?"));

    toUser = helper.SentMessage("Learn BotFramework");
    Assert.IsTrue(toUser[0].Text.Equals("What is the detail?"));

    toUser = helper.SentMessage("Implement O365Bot");
    Assert.IsTrue(toUser[0].Text.Equals("When do you start? Use dd/MM/yyyy HH:mm format."));

    toUser = helper.SentMessage("01/07/2017 13:00");
    Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals("Is this all day event?"));

    toUser = helper.SentMessage("Yes");
    Assert.IsTrue(toUser[0].Text.Equals("The event is created."));
}

[TestMethod]
public void Function_ShouldCreateEvent()
{
    DirectLineHelper helper = new DirectLineHelper(TestContext);
    var toUser = helper.SentMessage("add appointment");

    // Verify the result
    Assert.IsTrue(toUser[0].Text.Equals("Creating an event."));
    Assert.IsTrue(toUser[1].Text.Equals("What is the title?"));

    toUser = helper.SentMessage("Learn BotFramework");
    Assert.IsTrue(toUser[0].Text.Equals("What is the detail?"));

    toUser = helper.SentMessage("Implement O365Bot");
    Assert.IsTrue(toUser[0].Text.Equals("When do you start? Use dd/MM/yyyy HH:mm format."));

    toUser = helper.SentMessage("01/07/2017 13:00");
    Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals("Is this all day event?"));

    toUser = helper.SentMessage("No");
    Assert.IsTrue(toUser[0].Text.Equals("How many hours?"));

    toUser = helper.SentMessage("4");
    Assert.IsTrue(toUser[0].Text.Equals("The event is created."));
}

Check-in the code to VSTS to confirm if all the tests are passed.

Summery

DialogPrompt is very powerful and flexible, but not too easy. I explain FormFlow which makes everything easier next.

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

Ken