Bot Framework と Microsoft Graph で DevOps その 19 : LUIS と多言語対応ボットの実装編

※2017/6/19 ユニットテストで必要なファイルが抜けていたため、追加

前回は LUIS 応用編ということでエンティティやフォームフローとの連携を紹介しました。今回は開発中のボットアプリに組み込むところとユニットテストについて見ていきます。

多言語対応

将来的に日本語で datetimeV2 が出れば別ですが、今回は英語版の LUIS を使います。よってユーザーからのメッセージは一旦翻訳しましょう。

翻訳サービス

Cognitive の翻訳サービスを使います。詳細はこちら。ただ、探し方が悪いのか C# SDK がなかった。。

1. Azure ポータルから Translator Text API のキーを取得します。

image

2. ボットアプリプロジェクトの Services フォルダに ITranslationService.cs を追加し、コードを差し替え。

using System.Threading.Tasks;

namespace O365Bot.Services
{
    public interface ITranslationService
    {
        Task<string> Translate(string content, string from, string to);
    }
}

3. TranslationService.cs を追加し、コードを差し替え。

using System;
using System.Configuration;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace O365Bot.Services
{
    public class TranslationService : ITranslationService
    {
        private const string translateUri = "https://api.microsofttranslator.com/V2/Http.svc/Translate?text=";
        private const string authUri = "https://api.cognitive.microsoft.com/sts/v1.0/issueToken";

        public async Task<string> Translate(string content, string from, string to)
        {
            var token = await GetAccessToken();
            if (string.IsNullOrEmpty(token))
                throw new Exception("failed to auth translate API");

            using (HttpClient client = new HttpClient())
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
                var res = await client.GetAsync($"{translateUri}{Uri.EscapeUriString(content)}&from={from}&to={to}");
                if (res.IsSuccessStatusCode)
                {
                    XDocument xdoc = XDocument.Parse(await res.Content.ReadAsStringAsync());
                    return (xdoc.FirstNode as XElement).Value;
                }
                else
                    return "";
            }
        }

        /// <summary>
        /// アクセストークンの取得
        /// </summary>
        private async Task<string> GetAccessToken()
        {
            using(HttpClient client = new HttpClient())
            {
                client.DefaultRequestHeaders.TryAddWithoutValidation("Ocp-Apim-Subscription-Key", ConfigurationManager.AppSettings["TranslationKey"]);
                var res = await client.PostAsync(authUri, null);
                if (res.IsSuccessStatusCode)
                    return await res.Content.ReadAsStringAsync();
                else
                    return "";
            }
        }        
    }
}

4. Global.asax.cs の Application_Start を以下に差し替え。ITranslationService を追加しています。

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>();
    builder.RegisterType<TranslationService>().As<ITranslationService>();

    Container = builder.Build();
}

LUIS ダイアログでの翻訳

LUIS ダイアログに翻訳した文章を渡したい場合、 GetLuisQueryTextAsync をオーバーライドすることで実現できます。LuisRootDiaog.cs に以下のメソッドを追加します。

/// <summary>
/// LUIS にわたるテキストの翻訳
/// </summary>
protected override async Task<string> GetLuisQueryTextAsync(IDialogContext context, IMessageActivity message)
{
    using (var scope = WebApiApplication.Container.BeginLifetimeScope())
    {
        ITranslationService service = scope.Resolve<ITranslationService>();
        return await service.Translate(message.Text, message.Locale, "en");
    }
}

この場合 LUIS に対する処理だけが翻訳され、元の文章は保持されます。

エンティティの解析と処理フローの検討

LUIS により一部のフィールドはエンティティとして取得が可能となります。そのことを踏まえて処理フローも検討の余地が出てきます。エンティティの解析と、処理フローを一旦以下の様に変更してみました。CreateEventDialog.cs を差し替えます。

using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Bot.Builder.Luis.Models;
using Microsoft.Bot.Connector;
using Microsoft.Graph;
using Newtonsoft.Json.Linq;
using O365Bot.Models;
using O365Bot.Resources;
using O365Bot.Services;
using System;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class CreateEventDialog : IDialog<bool> // このダイアログが完了時に返す型
    {
        LuisResult luisResult;
        
        public CreateEventDialog(LuisResult luisResult)
        {
            this.luisResult = luisResult;
        }

        public async Task StartAsync(IDialogContext context)
        {
            var @event = new OutlookEvent();

            // LuisResult の Entities から初期値を設定
            foreach (EntityRecommendation entity in luisResult.Entities)
            {
                switch (entity.Type)
                {
                    case "Calendar.Subject":
                        @event.Subject = entity.Entity;
                        break;
                    // 日付型は様々なパターンで返ってくるので必要に応じて処理
                    case "builtin.datetimeV2.datetimerange":
                    case "builtin.datetimeV2.datetime":
                    case "builtin.datetimeV2.duration":
                    case "builtin.datetimeV2.date":
                    case "builtin.datetimeV2.time":
                        foreach (var vals in entity.Resolution.Values)
                        {
                            switch(((JArray)vals).First.SelectToken("type").ToString())
                            {
                                // 範囲の場合、スタートと期間を処理
                                case "datetimerange":
                                    var start = (DateTime)((JArray)vals).First["start"];
                                    var end = (DateTime)((JArray)vals).Last["end"];
                                    @event.Start = start;
                                    @event.Hours = end.Hour - start.Hour;
                                    break;
                                case "datetime":
                                case "date":
                                    @event.Start = (DateTime)((JArray)vals).Last["value"];
                                    break;
                                case "time":
                                    @event.Start =DateTime.Now.Date.Add(((DateTime)((JArray)vals).Last["value"]).TimeOfDay);
                                    break;
                                // Duration は秒で返ってくる
                                case "duration":
                                    @event.Hours = (int)((JArray)vals).Last["value"] / 60 * 60;
                                    break;
                            }                            
                        }
                        break;
                }
            }

            // 結果から全日イベントか確認
            if (@event.Start.Hour == 0 && @event.Hours == 0)
                @event.IsAllDay = true;
            
            @event.Description = (context.Activity as Activity).Text;

            if (string.IsNullOrEmpty(@event.Subject))
                @event.Subject = (context.Activity as Activity).Text;

            // FormFlow に初期値を渡して実行
            var outlookEventFormDialog = new FormDialog<OutlookEvent>(@event, BuildOutlookEventForm, FormOptions.PromptInStart);
            context.Call(outlookEventFormDialog, this.ResumeAfterDialog);
        }

        private async Task ResumeAfterDialog(IDialogContext context, IAwaitable<OutlookEvent> result)
        {
            await context.PostAsync(O365BotLabel.Event_Created);

            // ダイアログの完了を宣言
            context.Done(true);
        }

        public static IForm<OutlookEvent> BuildOutlookEventForm()
        {
            OnCompletionAsyncDelegate<OutlookEvent> processOutlookEventCreate = async (context, state) =>
            {
                using (var scope = WebApiApplication.Container.BeginLifetimeScope())
                {
                    IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context));
                    // TimeZone は https://graph.microsoft.com/beta/me/mailboxSettings で取得可能だがここでは一旦ハードコード
                    Event @event = new Event()
                    {
                        Subject = state.Subject,
                        Start = new DateTimeTimeZone() { DateTime = state.Start.ToString(), TimeZone = "Tokyo Standard Time" },
                        IsAllDay = state.IsAllDay,
                        End = state.IsAllDay ? null : new DateTimeTimeZone() { DateTime = state.Start.AddHours(state.Hours).ToString(), TimeZone = "Tokyo Standard Time" },
                        Body = new ItemBody() { Content = state.Description, ContentType = BodyType.Text }
                    };
                    await service.CreateEvent(@event);
                }
            };

            return new FormBuilder<OutlookEvent>()
                .Message(O365BotLabel.Event_Create)
                .Field(nameof(OutlookEvent.Subject))
                .Field(nameof(OutlookEvent.Start), active: (state) =>
                {
                    // Start に値があるかは初期値と比較
                    if (state.Start == default(DateTime))
                        return true;
                    else
                        return false;
                })
                .Field(nameof(OutlookEvent.Hours), active: (state) =>
                {
                    // 期間に値があるかは初期値と比較
                    if (state.Hours == default(double) && !state.IsAllDay)
                        return true;
                    else
                        return false;
                })
                .Field(nameof(OutlookEvent.Description))
                .OnCompletion(processOutlookEventCreate)
                .Build();
        }        
    }
}

不要なファイルの削除

もう RootDialog.cs は使わないため、削除するか全体をコメントアウトします。テストプロジェクトが RootDialog を参照しているため、ボットアプリだけをビルドして実行します。

エミュレーターでの検証とデバッグ

1. LuisRootDiaog.cs の GetLuisQueryTextAsync メソッドと CreateEventDialog の StartAsync にブレークポイントを置きます。

2. エミュレータで接続して、「金曜日午後7時から、同僚とステーキを食べに行く」と入力して送ります。ブレークしたらテキストを確認。

image

3. 認証が必要な場合は認証を行って続行。CreateEventDialog に来ても、元の文章が保持されていることを確認。

image

4. LuisResult では翻訳された文章が渡されていることを確認。

image

5. 実行してエミュレータに表示される質問を確認。

image

6. この時点ですでに入っているデータを確認するため、「ステータス」と送信。Subject が取れているため、英語になっていますが、とりあえず進めます。

image

7. ステーキ食べるのはきっと 3 時間くらいなので、「3」を送信。

8. 作成されたイベントを確認。

image

テストの作成

ユニットテスト

1. UnitTest1.cs はもう不要なのでコメントアウトしておきます。

2. Mock された LUIS を LUIS ダイアログに渡せるよう、LuisRootDialog.cs にコンストラクターを追加します。

public LuisRootDialog(params ILuisService[] services) :base(services)
{
}

3. LuisUnitTest1.cs を以下のコードと差し替えます。基本的には UnitTest1.cs でやっていた事と同じですが、LUIS で Entity を返すテストが入ります。簡略化のため、他のテストはここには入れていませんが、自分で書いてみてください。また datetimeV2 に対応していないため、ちょっと書くのが面倒です。。

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

namespace O365Bot.UnitTests
{
    [TestClass]
    public class SampleLuisTest : LuisTestBase
    {
        private string locale = "ja-JP";

        public SampleLuisTest()
        {            
            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);
        }

        [TestMethod]
        public async Task ShouldReturnEvents()
        {
            // Fakes を使うためにコンテキストを作成
            using (ShimsContext.Create())
            {
                // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定
                AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = 
                    async (a, e) => { return "dummyToken"; };
                
                // LUIS サービスのモック
                var luis1 = new Mock<ILuisService>();
                // 他依存サービスのモック
                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 subscriptionId = Guid.NewGuid().ToString();
                var mockNotificationService = new Mock<INotificationService>();
                mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId);
                mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true));

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

                // テストしたい LUIS ダイアログのインスタンス作成
                LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object);

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(luis1.Object))
                using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object))
                {
                    var dialogBuilder = new ContainerBuilder();
                    dialogBuilder
                        .RegisterInstance(rootDialog)
                        .As<IDialog<object>>();
                    dialogBuilder.Update(container);

                     // グローバルメッセージ登録
                    RegisterBotModules(container);

                    // LUIS の結果を GetEvents に指定
                    SetupLuis<LuisRootDialog>(luis1, d => d.GetEvents(null, null, null), 1.0, null);
                    
                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    toBot.From.Id = Guid.NewGuid().ToString();
                    // Locale 設定
                    toBot.Locale = locale;
                    toBot.Text = "予定一覧";

                    // メッセージを送信して、結果を受信
                    IMessageActivity toUser = await GetResponse(container, MakeRoot, toBot);

                    // 結果の検証
                    Assert.IsTrue(toUser.Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event"));
                }
            }
        }

        [TestMethod]
        public async Task ShouldCreateAllDayEvent()
        {
            // Fakes を使うためにコンテキストを作成
            using (ShimsContext.Create())
            {
                // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定
                AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString =
                    async (a, e) => { return "dummyToken"; };

                // LUIS サービスのモック
                var luis1 = new Mock<ILuisService>();
                // 他依存サービスのモック

                // サービスのモック
                var mockEventService = new Mock<IEventService>();
                mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));
                var subscriptionId = Guid.NewGuid().ToString();
                var mockNotificationService = new Mock<INotificationService>();
                mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId);
                mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true));
                var mockTranslationService = new Mock<ITranslationService>();
                mockTranslationService.Setup(x => x.Translate(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync("find  sentence");

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

                // テストしたい LUIS ダイアログのインスタンス作成
                LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object);

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(luis1.Object))
                using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object))
                {
                    var dialogBuilder = new ContainerBuilder();
                    dialogBuilder
                        .RegisterInstance(rootDialog)
                        .As<IDialog<object>>();
                    dialogBuilder.Update(container);

                    // グローバルメッセージ登録
                    RegisterBotModules(container);

                    // datetimeV2 の resolution 作成
                    Dictionary<string, object> resolution = new Dictionary<string, object>();
                    JArray values = new JArray();
                    Dictionary<string, object> resolutionData = new Dictionary<string, object>();
                    resolutionData.Add("type", "date");
                    resolutionData.Add("value", DateTime.Now);
                    values.Add(JToken.FromObject(resolutionData));                    
                    resolution.Add("values", values);

                    // LUIS の結果を CreateEvent に指定し、Entity を設定
                    SetupLuis<LuisRootDialog>(luis1, d => d.CreateEvent(null, null, null), 1.0,
                        EntityFor("builtin.datetimeV2.date", "", resolution),
                        EntityFor("Calendar.Subject", "dummy subject"));
                    
                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    // ロケールで日本語を指定
                    toBot.Locale = locale;
                    toBot.From.Id = Guid.NewGuid().ToString();
                    toBot.Text = "7月1日に食事に行く";

                    // メッセージを送信して、結果を受信
                    var toUser = await GetResponses(container, MakeRoot, toBot);

                    // 結果の検証
                    Assert.IsTrue(toUser.Last().Text.Equals(O365Bot_Models_OutlookEvent.Hours_promptDefinition_LIST));

                    toBot.Text = "3";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser.First().Text.Equals(O365BotLabel.Event_Created));
                }
            }
        }

        [TestMethod]
        public async Task ShouldCreateEvent()
        {
            // Fakes を使うためにコンテキストを作成
            using (ShimsContext.Create())
            {
                // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定
                AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString =
                    async (a, e) => { return "dummyToken"; };

                // LUIS サービスのモック
                var luis1 = new Mock<ILuisService>();
                // 他依存サービスのモック

                // サービスのモック
                var mockEventService = new Mock<IEventService>();
                mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));
                var subscriptionId = Guid.NewGuid().ToString();
                var mockNotificationService = new Mock<INotificationService>();
                mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId);
                mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true));
                var mockTranslationService = new Mock<ITranslationService>();
                mockTranslationService.Setup(x => x.Translate(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync("find  sentence");

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

                // テストしたい LUIS ダイアログのインスタンス作成
                LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object);

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(luis1.Object))
                using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object))
                {
                    var dialogBuilder = new ContainerBuilder();
                    dialogBuilder
                        .RegisterInstance(rootDialog)
                        .As<IDialog<object>>();
                    dialogBuilder.Update(container);

                    // グローバルメッセージ登録
                    RegisterBotModules(container);

                    // datetimeV2 の resolution 作成
                    Dictionary<string, object> resolution = new Dictionary<string, object>();
                    JArray values = new JArray();
                    Dictionary<string, object> resolutionData = new Dictionary<string, object>();
                    resolutionData.Add("type", "date");
                    resolutionData.Add("value", DateTime.Now.Date);
                    values.Add(JToken.FromObject(resolutionData));
                    resolution.Add("values", values);

                    // LUIS の結果を CreateEvent に指定し、Entity を設定
                    SetupLuis<LuisRootDialog>(luis1, d => d.CreateEvent(null, null, null), 1.0,
                        EntityFor("builtin.datetimeV2.date", "", resolution),
                        EntityFor("Calendar.Subject", "dummy subject"));

                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    // ロケールで日本語を指定
                    toBot.Locale = locale;
                    toBot.From.Id = Guid.NewGuid().ToString();
                    toBot.Text = "7月1日に食事に行く";

                    // メッセージを送信して、結果を受信
                    var toUser = await GetResponses(container, MakeRoot, toBot);

                    // 結果の検証
                    Assert.IsTrue(toUser.Last().Text.Equals(O365BotLabel.Event_Created));
                }
            }
        }

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

        /// <summary>
        /// Bot にメッセージを送って、結果を受信
        /// </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>
        /// Bot にメッセージを送って、結果を受信
        /// </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>
        /// プロアクティブ通知でダイアログ差し込みを行い、結果を受信
        /// </summary>
        public async Task<List<IMessageActivity>> Resume(IContainer container, IDialog<object> dialog, IMessageActivity toBot)
        {
            using (var scope = DialogModule.BeginLifetimeScope(container, toBot))
            {
                var results = new List<IMessageActivity>();

                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);
                
                // 結果の取得
                var queue = scope.Resolve<Queue<IMessageActivity>>();
                while (queue.Count != 0)
                {
                    results.Add(queue.Dequeue());
                }

                return results;
            }
        }        
    }
}

4. Helper フォルダにLUIS をモックするためのコードを追加。LuisTestBase.cs を追加してコードを以下と差し替え。尚、このコードはいつも通り BotBuilder の GitHub から拝借。

using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Luis;
using Microsoft.Bot.Builder.Luis.Models;
using Moq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Bot.Builder.Tests
{
    public abstract class LuisTestBase : DialogTestBase
    {
        public static IntentRecommendation[] IntentsFor<D>(Expression<Func<D, Task>> expression, double? score)
        {
            var body = (MethodCallExpression)expression.Body;
            var attributes = body.Method.GetCustomAttributes<LuisIntentAttribute>();
            var intents = attributes
                .Select(attribute => new IntentRecommendation(attribute.IntentName, score))
                .ToArray();
            return intents;
        }

        public static EntityRecommendation EntityFor(string type, string entity, IDictionary<string, object> resolution = null)
        {
            return new EntityRecommendation(type: type) { Entity = entity, Resolution = resolution };
        }

        public static EntityRecommendation EntityForDate(string type, DateTime date)
        {
            return EntityFor(type,
                date.ToString("d", DateTimeFormatInfo.InvariantInfo),
                new Dictionary<string, object>()
                {
                    { "resolution_type", "builtin.datetime.date" },
                    { "date", date.ToString("yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo) }
                });
        }

        public static EntityRecommendation EntityForTime(string type, DateTime time)
        {
            return EntityFor(type,
                time.ToString("t", DateTimeFormatInfo.InvariantInfo),
                new Dictionary<string, object>()
                {
                    { "resolution_type", "builtin.datetime.time" },
                    { "time", time.ToString("THH:mm:ss", DateTimeFormatInfo.InvariantInfo) }
                });
        }

        public static void SetupLuis<D>(
            Mock<ILuisService> luis,
            Expression<Func<D, Task>> expression,
            double? score,
            params EntityRecommendation[] entities
            )
        {
            luis
                .Setup(l => l.QueryAsync(It.IsAny<Uri>(), It.IsAny<CancellationToken>()))
                .ReturnsAsync(new LuisResult()
                {
                    Intents = IntentsFor(expression, score),
                    Entities = entities
                });
        }

        public static void SetupLuis<D>(
            Mock<ILuisService> luis,
            string utterance,
            Expression<Func<D, Task>> expression,
            double? score,
            params EntityRecommendation[] entities
            )
        {
            var uri = new UriBuilder() { Query = utterance }.Uri;
            luis
                .Setup(l => l.BuildUri(It.Is<LuisRequest>(r => r.Query == utterance)))
                .Returns(uri);

            luis.Setup(l => l.ModifyRequest(It.IsAny<LuisRequest>()))
                .Returns<LuisRequest>(r => r);

            luis
                .Setup(l => l.QueryAsync(uri, It.IsAny<CancellationToken>()))
                .Returns<Uri, CancellationToken>(async (_, token) =>
                {
                    return new LuisResult()
                    {
                        Intents = IntentsFor(expression, score),
                        Entities = entities
                    };
                });
        }
    }
}

ファンクションテスト

ファンクションテストも同様に変更してください。

まとめ

LUIS を使うと一気に高度になった感じがあります。BotBuilder で datatimeV2 に対応してくれると、テストコードもシンプルに書けそうです。