Create Bot for Microsoft Graph with DevOps 11: BotBuilder features – Global Message Hanlders

Users may want to say “help” in middle of a dialog. As a developer, you can implement global message handler to handle these “keywords”. Read the article here for more detail.

Implement cancel operation

Let’s implement one of the most common global handler, “cancel”.

1. Add Scorables folder in O365Bot project, and add CancelScorable.cs. In this class, you specify “cancal” as keyword and take action whenever user sends the keyword.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Builder.Internals.Fibers;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Builder.Scorables.Internals;

namespace O365Bot.Scorables
{

#pragma warning disable 1998

    public class CancelScorable : ScorableBase<IActivity, string, double>
    {
        private readonly IDialogTask task;

        public CancelScorable(IDialogTask task)
        {
            SetField.NotNull(out this.task, nameof(task), task);
        }

        /// <summary>
        /// Compare user input with keyword.
        /// </summary>
        protected override async Task<string> PrepareAsync(IActivity activity, CancellationToken token)
        {
            var message = activity as IMessageActivity;

            if (message != null && !string.IsNullOrWhiteSpace(message.Text))
            {
                if (message.Text.ToLower().Equals("cancel", StringComparison.InvariantCultureIgnoreCase))
                {
                    return message.Text;
                }
            }

            return null;
        }

        protected override bool HasScore(IActivity item, string state)
        {
            return state != null;
        }

        protected override double GetScore(IActivity item, string state)
        {
            return 1.0;
        }

        /// <summary>
        /// If keyword found, then reset the current dialog.
        /// </summary>
        protected override async Task PostAsync(IActivity item, string state, CancellationToken token)
        {
            this.task.Reset();
        }

        protected override Task DoneAsync(IActivity item, string state, CancellationToken token)
        {
            return Task.CompletedTask;
        }
    }
}

2. Add GlobalMessageHandlers.cs file in the root and replace the code. In this code, register the CancelScorable.

using Autofac;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Builder.Scorables;
using Microsoft.Bot.Connector;
using O365Bot.Scorables;

namespace O365Bot
{

    public class GlobalMessageHandlers : Module
    {
        protected override void Load(ContainerBuilder builder)
        {
            base.Load(builder);

            builder
                .Register(c => new CancelScorable(c.Resolve<IDialogTask>()))
                .As<IScorable<IActivity, double>>()
                .InstancePerLifetimeScope();
        }
    }
}

3. Replace the Global.asax.cs to register the handler on startup. As this is part of Conversation Autofac, using Update method to directly insert the build information.

using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using O365Bot.Services;
using System.Configuration;
using System.Web.Http;

namespace O365Bot
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        public static IContainer Container;

        protected void Application_Start()
        {
            this.RegisterBotModules();
            GlobalConfiguration.Configure(WebApiConfig.Register);

            AuthBot.Models.AuthSettings.Mode = ConfigurationManager.AppSettings["ActiveDirectory.Mode"];
            AuthBot.Models.AuthSettings.EndpointUrl = ConfigurationManager.AppSettings["ActiveDirectory.EndpointUrl"];
            AuthBot.Models.AuthSettings.Tenant = ConfigurationManager.AppSettings["ActiveDirectory.Tenant"];
            AuthBot.Models.AuthSettings.RedirectUrl = ConfigurationManager.AppSettings["ActiveDirectory.RedirectUrl"];
            AuthBot.Models.AuthSettings.ClientId = ConfigurationManager.AppSettings["ActiveDirectory.ClientId"];
            AuthBot.Models.AuthSettings.ClientSecret = ConfigurationManager.AppSettings["ActiveDirectory.ClientSecret"];

            var builder = new ContainerBuilder();
            builder.RegisterType<GraphService>().As<IEventService>();
            Container = builder.Build();
        }

        private void RegisterBotModules()
        {
            var builder = new ContainerBuilder();
            builder.RegisterModule(new ReflectionSurrogateModule());
            builder.RegisterModule<GlobalMessageHandlers>();
            builder.Update(Conversation.Container);
        }
    }
}

Try with emulator

Run the application and try with emulator.
image

Implement Interruption

What if user wants to see the events while creating one? You can use same global message handler technic.

1. Add GetEventsScorable.cs in Scorables folder and replace code. This is very similar to previous one, but inserting new dialog when the keyword is detected, rather than canceling the current dialog.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Builder.Internals.Fibers;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Builder.Scorables.Internals;
using O365Bot.Dialogs;

namespace O365Bot.Scorables
{
#pragma warning disable 1998

    public class GetEventsScorable : ScorableBase<IActivity, string, double>
    {
        private readonly IDialogTask task;

        public GetEventsScorable(IDialogTask task)
        {
            SetField.NotNull(out this.task, nameof(task), task);
        }

        protected override async Task<string> PrepareAsync(IActivity activity, CancellationToken token)
        {
            var message = activity as IMessageActivity;

            if (message != null && !string.IsNullOrWhiteSpace(message.Text))
            {
                if (message.Text.Equals("get events", StringComparison.InvariantCultureIgnoreCase))
                {
                    return message.Text;
                }
            }

            return null;
        }

        protected override bool HasScore(IActivity item, string state)
        {
            return state != null;
        }

        protected override double GetScore(IActivity item, string state)
        {
            return 1.0;
        }

        /// <summary>
        /// If keyword found, then inset dialog
        /// </summary>
        protected override async Task PostAsync(IActivity item, string state, CancellationToken token)
        {
            var message = item as IMessageActivity;

            if (message != null)
            {
                var getEventsDialog = new GetEventsDialog();

                var interruption = getEventsDialog.Void<bool, IMessageActivity>();

                await this.task.Forward(interruption, null, message, CancellationToken.None);

                await this.task.PollAsync(token);
            }
        }

        protected override Task DoneAsync(IActivity item, string state, CancellationToken token)
        {
            return Task.CompletedTask;
        }
    }
}

2. Add following method in GlobalMessageHandlers.cs

builder
    .Register(c => new GetEventsScorable(c.Resolve<IDialogTask>()))
    .As<IScorable<IActivity, double>>()
    .InstancePerLifetimeScope();

Try with emulator

Run the application and try with emulator.

Update tests

As I implemented new features, let’s update tests, too.

Unit Test

For unit test, adding Global Message Handler registration method and call it from every test. You need to be careful which Container to register the handler.

using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Tests;
using Microsoft.Bot.Connector;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading.Tasks;
using Autofac;
using O365Bot.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Builder.Base;
using System.Threading;
using System.Collections.Generic;
using Microsoft.QualityTools.Testing.Fakes;
using O365Bot.Services;
using Moq;
using Microsoft.Graph;
using System.Globalization;
using Microsoft.Bot.Builder.Internals.Fibers;

namespace O365Bot.UnitTests
{
    [TestClass]
    public class SampleDialogTest : DialogTestBase
    {
        [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"; };

                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 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))
                {
                    // Register global message handler
                    RegisterBotModules(container);

                    // 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 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))
                {
                    // Register global message handler
                    RegisterBotModules(container);

                    // 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))
                {
                    // Register global message handler
                    RegisterBotModules(container);

                    // 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."));
                }
            }
        }

        [TestMethod]
        public async Task ShouldCancelCurrrentDialog()
        {
            // 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))
                {
                    // Register global message handler
                    RegisterBotModules(container);
                                        
                    // 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 = "Cancel";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser.Count.Equals(0));

                    toBot.Text = "add appointment";
                    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?"));
                }
            }
        }

        [TestMethod]
        public async Task ShouldInterruptCurrentDialog()
        {
            // 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));
                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 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))
                {
                    // Register global message handler
                    RegisterBotModules(container);

                    // 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 = "Get Events";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event"));

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


        /// <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.Update(container);
        }

    }
}

Function Test

Simple add following two tests.

[TestMethod]
public void Function_ShouldCancelCurrrentDialog()
{
    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("Cancel");
    Assert.IsTrue(toUser.Count.Equals(0));

    toUser = helper.SentMessage("add appointment");
    Assert.IsTrue(toUser[0].Text.Equals("Creating an event."));
    Assert.IsTrue(toUser[1].Text.Equals("What is the title?"));
}

[TestMethod]
public void Function_ShouldInterruptCurrentDialog()
{
    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("Get Events");
    Assert.IsTrue(true);

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

Checkin the code to make sure all tests are passed.

Summery

Global message handling is one of the key to make intelligent bot. In real scenario, you may want to put several keywords per scorables.

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

Ken