Bot Framework と Microsoft Graph で DevOps その 16 : 会話のインターセプト処理

今回はボットアプリとユーザー間のやり取りを、インターセプトする方法を紹介します。

概要

ミドルウェアを会話の前後に差し込めると、会話のログ取得や、会話のフォワードなど、様々なことを既存のコードに影響を与えることなくできます。

インターセプトの実装

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

using Microsoft.Bot.Builder.History;
using Microsoft.Bot.Connector;
using System.Diagnostics;
using System.Threading.Tasks;

namespace O365Bot.Services
{
    public class ActivityLogger : IActivityLogger
    {
        public async Task LogAsync(IActivity activity)
        {
            Debug.WriteLine($"From:{activity.From.Id} - To:{activity.Recipient.Id} - Message:{activity.AsMessageActivity()?.Text}");
        }
    }
}

2. Global.asax.cs に処理差し込み用のコードを追加します。以下のコードと差し替えます。自分用の IoC Container と登録先を間違えないようにしてください。

using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Internals.Fibers;
using O365Bot.Handlers;
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>();
            builder.RegisterType<GraphService>().As<INotificationService>();

            Container = builder.Build();
        }
        
        private void RegisterBotModules()
        {
            var builder = new ContainerBuilder();
            // グローバルメッセージの処理登録
            builder.RegisterModule(new ReflectionSurrogateModule());
            builder.RegisterModule<GlobalMessageHandlers>();
            // インターセプトの登録
            builder.RegisterType<ActivityLogger>().AsImplementedInterfaces().InstancePerDependency();
            builder.Update(Conversation.Container);
        }
    }
}

エミュレーターでの検証

1. 前回実装したプロアクティブ通知のコードがあると、エミュレーターからの検証が少し面倒なため、RootDialog.cs の DoWork メソッドを以下のコードに差し替えて、エミュレーターからの場合通知の登録をしないようにします。

private async Task DoWork(IDialogContext context, IMessageActivity message)
{
    if (message.ChannelId != "emulator")
    {
        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);
}

2. エミュレーターを起動して接続。

3. メッセージを送信。

4. Visual Studio の Output ビューに会話が記録されていることを確認。

image

テストの追加

今回は、ログを取得しかしていないため、ユーザー操作には影響がありません。よってファンクションテストは書きませんが、ユニットテストには、インターセプターを登録して、既存テストに影響ないか確認しておきます。

1. UnitTest1.cs の RegisterBotModules メソッドを以下のコードに差し替えます。

/// <summary>
/// グローバルメッセージおよびインターセプター登録
/// </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);
}

2. すべてのユニットテストを実行します。

具体的な用途

今回はただのロギングでしたが、ログも実際は DocumentDB などに格納すると後で解析が楽にできます。その他の便利なシナリオは、人間へのハンドオフです。これはユーザーがボットと話している過程で、人間の介入が必要になった場合に切り替える処理のことです。サンプルが以下にあるので、是非ご覧ください。

https://github.com/tompaana/intermediator-bot-sample

皆さんならどんなシナリオで使うかも、是非教えてください。

まとめ

差し込みの入り口はとても簡単でしたが、できることが無限にあるので楽しいですね。影響があるものはユニットテスト、ファンクションテストともに書いてください。次回は多言語を理解する仕組みとして、LUIS  (Language Understanding Intelligent Service) との統合を紹介します。