Bot Framework と Microsoft Graph で DevOps その 14 : プロアクティブ通知 実装編

※2017/6/11 一部コードを変更。

今回はボットアプリからプロアクティブにユーザーに話しかける方法について。内容多いので実装編とテスト編に分けて紹介します。

概要

本家のドキュメントが面白いので引用。https://docs.microsoft.com/en-us/bot-framework/dotnet/bot-builder-dotnet-proactive-messages

プロアクティブメッセージが生きるシナリオがいくつかあります。タイマーやリマインダーを設定した場合や、外部システムから通知を受け取った場合です。もしユーザーが、ある商品の値段をチェックするようボットアプリに依頼した場合、価格に変動があれば通知します。別の例としては、回答に時間を要する場合、後から連絡するということもあります。

プロアクティブ通知を実装する場合、以下の点に注意。

  • 短時間に複数の通知を送らないこと。チャネルによっては制限がかかりボットアプリが停止されます。
  • 会話したことのないユーザーにいきなり通知を送らない。

会話中に通知を送る場合

例えば次のような会話を考えます。

how users talk

ロンドン旅行の予約をしようとしている際、前にモニターするよう指示しておいた、ラスベガスのホテルの価格が安くなったよ!と通知を受け、「え?なに?」と思わず聞いたら、それは日付ではありませんと言われる。明らかにボットアプリはロンドン旅行の予約のダイアログに戻っていますが、ユーザーからすると混乱するだけです。このような場合、以下の対処が考えられます。

  • 現在のダイアログが終わるまで待ってから、通知を行う。会話に対する影響は減りますが、通知が遅くなります。ラスベガスにも行けなくなるかも。
  • 現在のダイアログを一旦キャンセルして、通知をします。通知はリアルタイムですが、ロンドンに行きたい場合、また初めからやり直し。
  • 現在のダイアログを保留して、トピックを明示的に変えます。ラスベガスのホテルについての話が終わったら、またロンドン旅行のダイアログに戻します。これが最良にも思えますが、ユーザーにとっても、開発者にとっても、複雑な処理になりえます。

通知の種類

プロアクティブな通知は、2 つに分類できます。

  • アドホック通知: ユーザーが現在会話しているかどうかに関わらず、通知を送る方法。現在の会話を変えることはありません。
  • ダイアログに基づいた通知: 現在の会話を把握して、通知のダイアログを差し込みます。通知のダイアログが終われば、元の会話に戻します。

予定の通知機能を実装

今回 O365 ボットには、イベントが変更されたことを通知する機能を付けます。各種コードは BotBuilder のサンプルから拝借します。

ダイアログの差し込み

1. ボットアプリの Services フォルダに ConversationStarter.cs ファイルを追加し、以下のコードと差し替えます。

 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.Services
{
    public class ConversationStarter
    {
        /// <summary>
        /// 現在の会話に、指定したダイアログを差し込む
        /// </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. 現在の情報を格納するため、Services フォルダに Cache.cs ファイルを追加し、以下のコードと差し替えます。

 using System.Collections.Generic;

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

3. 現在の会話を格納するため、RootDialog.cs の DoWork メソッドを以下のコードに差し替えます。

 private async Task DoWork(IDialogContext context, IMessageActivity message)
{
    using (var scope = WebApiApplication.Container.BeginLifetimeScope())
    {
        var service = scope.Resolve<INotificationService>(new TypedParameter(typeof(IDialogContext), context));

        // 変更を購読
        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);

        // 会話情報を購読 id をキーに格納
        var conversationReference = message.ToConversationReference();

        if (CacheService.caches.ContainsKey(subscriptionId))
            CacheService.caches[subscriptionId] = conversationReference;
        else
            CacheService.caches.Add(subscriptionId, conversationReference);
        // 会話情報はロケールを保存しないようなので、こちらも保存
        if (!CacheService.caches.ContainsKey(message.From.Id))
            CacheService.caches.Add(message.From.Id, Thread.CurrentThread.CurrentCulture.Name);       
    }

    if (message.Text.Contains("get"))
        // GetEventDialog を呼び出し、完了時に ResumeAfterDialog を実行
        await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None);
    else if (message.Text.Contains("add"))
        // ユーザー入力を引き渡す必要はないので、CreateEventDialog を Call で呼び出す。
        context.Call(new CreateEventDialog(), ResumeAfterDialog);
}

4. 外部からの通知を受け取るために、Controllers フォルダに NotificationsController.cs ファイル追加し、以下のコードと差し替えます。

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

            // 購読時の検証
            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()))
                        {
                            // 現在の会話情報を、購読 ID を元に取りだす。
                            var conversationReference = CacheService.caches[subscription.AdditionalData["subscriptionId"].ToString()] as ConversationReference;
                            // イベント ID を取り出す。
                            var id = ((dynamic)subscription.AdditionalData["resourceData"]).id.ToString();

                            // ロケールを取り出して設定
                            var activity = conversationReference.GetPostToBotMessage();
                            var locale = CacheService.caches[activity.From.Id].ToString();
                            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
                            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);

                            // 通知のダイアログを差し込む
                            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;
        }
    }
}

5. 通知用のダイアログを作成。Dialogs フォルダに NotifyEventChageDialog.cs を追加し、以下のコードを追加します。

 using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using O365Bot.Resources;
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[] {O365BotLabel.Ask_To_See_Detail, O365BotLabel.Ask_To_Go_Back }, O365BotLabel.Notification_Event_Changed);
        }

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

            if (answer == O365BotLabel.Ask_To_See_Detail)
            {
                await context.PostAsync(O365BotLabel.Get_Event_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(O365BotLabel.Go_Back_To_Original_Conversation);
            context.Done(String.Empty);
        }
    }
}

6. ダイアログで使うリソースを、リソースファイルに追加。

image image

7. AutoFac で適切に解決できるよう、Global.asax.cs の Application_Start メソッド内の適切な場所に以下を追加。

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

イベント変更の購読

Microsoft Graph の Webhook を利用して、イベントの変更があった際に通知を受け取るようにします。詳細はこちら

1. Services フォルダに INotificationService.cs を追加し、以下のコードと差し替えます。

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

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

2. IEventService.cs にイベントの取得を追加します。以下のコードと差し替えます。

 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. GraphService.cs に INotificationService インターフェースを追加して、各メソッドを追加します。

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

まとめ

手順は少ないですが、新しい情報が多いので今日はここまで。次回このコードの検証とテストの実装をします。忘れずにコードはチェックイン!