Create Bot for Microsoft Graph with DevOps 12: BotBuilder features – Implementing Proactive Messsaging

In this article, I implement proactive messaging feature to O365Bot. You can see the detail about proactive messaging here. There are several useful scenario for O365 notification. In this article, I implement event change notification as it is important to me.

Subscribe Microsoft Graph Event update

To receive Microsoft Graph Event update, you can use Webhook. See the detail here.

1. Add INotificationService.cs in Services folder and replace the code.

using System.Threading.Tasks;

namespace O365Bot.Services
{
    public interface INotificationService
    {
        Task<string> SubscribeEventChange();
        Task RenewSubscribeEventChange(string subscriptionId);
    }
}

2. Update IEventService.cs to get a single event.

using Microsoft.Graph;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace O365Bot.Services
{
    public interface IEventService
    {
        Task<List<Event>> GetEvents();
        Task CreateEvent(Event @event);
        Task<Event> GetEvent(string id);
    }
}

3. Inherit INotificationService in GraphService.cs and implement the method. In SubscribeEventChange method, I specify the currently running web app address as webhook callback.

using AuthBot;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Graph;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Web;

namespace O365Bot.Services
{
    public class GraphService : IEventService, INotificationService
    {
        IDialogContext context;
        public GraphService(IDialogContext context)
        {
            this.context = context;
        }

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

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

        public async Task<Event> GetEvent(string id)
        {
            var client = await GetClient();

            var @event = await client.Me.Events[id].Request().GetAsync();

            return @event;
        }

        public async Task<List<Event>> GetEvents()
        {
            var events = new List<Event>();
            var client = await GetClient();

            try
            {
                var calendarView = await client.Me.CalendarView.Request(new List<Option>()
                {
                    new QueryOption("startdatetime", DateTime.Now.ToString("yyyy/MM/ddTHH:mm:ssZ")),
                    new QueryOption("enddatetime", DateTime.Now.AddDays(7).ToString("yyyy/MM/ddTHH:mm:ssZ"))
                }).GetAsync();

                events = calendarView.CurrentPage.ToList();
            }
            catch (Exception ex)
            {
            }

            return events;
        }

        public async Task<string> SubscribeEventChange()
        {
            var client = await GetClient();
            var url = HttpContext.Current.Request.Url;
            if (url.Host == "localhost")
               return "";
            var webHookUrl = $"{url.Scheme}://{url.Host}:{url.Port}/api/Notifications";

            var res = await client.Subscriptions.Request().AddAsync(
            new Subscription()
            {
                ChangeType = "updated, deleted",
                NotificationUrl = webHookUrl,
                ExpirationDateTime = DateTime.Now.AddDays(1),
                Resource = $"me/events",
                ClientState = "event update or delete"
            });

            return res.Id;
        }

        public async Task RenewSubscribeEventChange(string subscriptionId)
        {
            var client = await GetClient();
            var subscription = new Subscription()
            {
                ExpirationDateTime = DateTime.Now.AddDays(1),
            };

            var res = await client.Subscriptions[subscriptionId].Request().UpdateAsync(subscription);
        }

        private async Task<GraphServiceClient> GetClient()
        {
            GraphServiceClient client = new GraphServiceClient(new DelegateAuthenticationProvider(AuthProvider));
            return client;
        }

        private async Task AuthProvider(HttpRequestMessage request)
        {
            request.Headers.Authorization = new AuthenticationHeaderValue(
            "bearer", await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]));
        }
    }
}

Notify the event change

There are at least two types of notifications. Simply notification and complex notification as a form of dialog. I will borrow several codes from BotBuilder samples.

Interrupt current conversation

1. To send a dialog as notification, we need to interrupt current conversation. The idea is similar to global messaging handler, but we interrupt the conversation from Bot application side, not user input. Add ConversationStarter.cs at root folder and replace the code.

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

namespace O365Bot
{
    public class ConversationStarter
    {
        /// <summary>
        /// Insert the dialog on current conversation.
        /// </summary>
        public static async Task Resume(Activity message, IDialog<object> dialog)
        {
            var client = new ConnectorClient(new Uri(message.ServiceUrl));

            using (var scope = DialogModule.BeginLifetimeScope(Conversation.Container, message))
            {
                var botData = scope.Resolve<IBotData>();
                await botData.LoadAsync(CancellationToken.None);
                var task = scope.Resolve<IDialogTask>();
                
                task.Call(dialog.Void<object, IMessageActivity>(), null);
                await task.PollAsync(CancellationToken.None);
                await botData.FlushAsync(CancellationToken.None);
            }
        }
    }
}

2. Add caching service to store current information. In production, you need to implement something scalable. Add CacheService.cs in Services folder.

using System.Collections.Generic;

namespace O365Bot.Services
{
    public static class CacheService
    {
        public static Dictionary<string, object> caches = new Dictionary<string, object>();
    }
}

3. Add using to RootDialog.cs

using Autofac;
using Microsoft.Bot.Builder.ConnectorEx;
using O365Bot.Services;

4. Update DoWork method in RootDialog.cs to store current conversation information. BotBuilder lets us store current conversation information as a form of ConversationReference. I store them by mapping subscription id of Microsoft Graph notification.

private async Task DoWork(IDialogContext context, IMessageActivity message)
{
    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))
        {
            // Subscribe to Microsoft Graph Notification and get 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);
    }

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

5. Add a dialog for notification event update. Add NotifyEventChageDialog.cs in Dialogs folder and replace code.

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

namespace O365Bot.Dialogs
{
    [Serializable]
    public class NotifyEventChageDialog : IDialog<object> 
    {
        private string id;
        public NotifyEventChageDialog(string id)
        {
            this.id = id;
        }

        public async Task StartAsync(IDialogContext context)
        {
            PromptDialog.Choice(context, this.AfterSelectOption, new string[] { "Check the detail", "Go back to current conversation." }, "One of your events has been updated.");
        }

        private async Task AfterSelectOption(IDialogContext context, IAwaitable<string> result)
        {
            var answer = await result;

            if (answer == "Check the detail")
            {
                await context.PostAsync("Check the detail");
                using (var scope = WebApiApplication.Container.BeginLifetimeScope())
                {
                    IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context));
                    var @event = await service.GetEvent(id);
                    await context.PostAsync($"{@event.Start.DateTime}-{@event.End.DateTime}: {@event.Subject}@{@event.Location.DisplayName}-{@event.Body.Content}");
                }
            }

            await context.PostAsync("Going back to the original conversation.");
            context.Done(String.Empty);
        }
    }
}

Add Controller

1. To accept notification request from external systems, add NotificationsController.cs file to Controllers folder and replace the code.

using Microsoft.Bot.Connector;
using Microsoft.Graph;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using O365Bot.Dialogs;
using O365Bot.Services;
using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http;
using cg = System.Collections.Generic;
namespace O365Bot
{
    public class NotificationsController : ApiController
    {
        public async Task<HttpResponseMessage> Post(object obj)
        {
            var response = Request.CreateResponse(HttpStatusCode.OK);

            // Verify the webhook subscription.
            if (Request.RequestUri.Query.Contains("validationToken"))
            {
                response.Content = new StringContent(Request.RequestUri.Query.Split('=')[1], Encoding.UTF8, "text/plain");
            }
            else
            {
                var subscriptions = JsonConvert.DeserializeObject<cg.List<Subscription>>(JToken.Parse(obj.ToString())["value"].ToString());
                try
                {
                    foreach (var subscription in subscriptions)
                    {
                        if (CacheService.caches.ContainsKey(subscription.AdditionalData["subscriptionId"].ToString()))
                        {
                            // Get ConversationReference by using SubscriptionId.
                            var conversationReference = CacheService.caches[subscription.AdditionalData["subscriptionId"].ToString()] as ConversationReference;
                            // Get the event id.
                            var id = ((dynamic)subscription.AdditionalData["resourceData"]).id.ToString();

                            // Get local id and set it.
                            var activity = conversationReference.GetPostToBotMessage();
                            var locale = CacheService.caches[activity.From.Id].ToString();
                            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
                            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);

                            // Interrupt current conversation.
                            await ConversationStarter.Resume(
                                activity,
                                new NotifyEventChageDialog(id));
                        }
                        var resp = new HttpResponseMessage(HttpStatusCode.OK);
                        return resp;
                    }
                }
                catch (Exception ex)
                {
                    return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ex);
                }

            }
            return response;
        }
    }
}

2. Register the type in Application_Start method in Global.asax.cs.

builder.RegisterType<GraphService>().As<INotificationService>();

Test with emulator

As Microsoft Graph webhook requires reachable URL, I need to test this after publish the bot.

However, as I didn’t implement test yet, CD/CI pipeline shall fail, thus simply publish it from Visual Studio this time for test purpose.

1. Run the emulator and open App Settings.

image

2. Follow the instruction on the screen to setup “ngrok” which lets the emulator connect to published bot.

image

3. Enter URL, App ID and Password to connect. Connect to the production instance.

image

4. Enter “add appointment”. You may need authentication.

image

5. After the authentication, start creating an event.

image

6. Then, update any event in your calendar to see if you get the notification.

image

7. Select option and confirm it returns to previous conversation at the end.

image

Check-in all source code to VSTS. Do not mind about test failure at this point.

Summery

Send rich complex notification is other key feature for intelligent bot. I will implement or consider unit and function testing for this in the next article.

Ken