Bot Framework と Microsoft Graph で DevOps その 12 : ボットアプリとテストの多言語対応

※2017/6/10 ユニットテストとファンクションテストのコードを修正。

今回はこれまで紹介したダイアログやフォームフローにも関係する、多言語対応について紹介します。

Activity の Locale プロパティ

Web API に送られてくる Activity には Locale プロパティがあります。ダイアログやフォームフローで作成されたダイアログはこの値をみて、動的に言語を切り替えます。ただすべてのチャネルで Locale セットしてくれるわけではありません。またダイアログ以外の独自のラベルは、別途多言語対応する必要があります。

エミュレーターでの言語指定

言語を接続時に指定できます。

image

多言語対応

リソースファイル

ボットアプリはただの Web API のため、resx ファイルを使った多言語対応が可能です。

1. ボットアプリプロジェクトに Resources フォルダを追加。

image

2. 新しいアイテムとしてリソースファイルを追加。名前は O365BotLabel.resx としました。これが既定の言語リソースになります。

3. Create_Event を追加し、値として Creating an event. と設定。

image

4. 次に同じフォルダ内に、O365BotLabel.ja.resx リソースファイルを追加。以下のように同じリソースを追加。

image

5. CreateEventDialog.cs を開いて、コードを差し替え。ハードコードされたコメントをリソースに入れ替えました。また BuildOutlookEventForm を public static にしています。

using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Graph;
using O365Bot.Models;
using O365Bot.Resources;
using O365Bot.Services;
using System;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class CreateEventDialog : IDialog<bool> // このダイアログが完了時に返す型
    {
        public async Task StartAsync(IDialogContext context)
        {
            // FormFlow でダイアログを作成して、呼び出し。
            var outlookEventFormDialog = FormDialog.FromForm(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.Description))
                .Field(nameof(OutlookEvent.Start))
                .Field(nameof(OutlookEvent.IsAllDay))
                .Field(nameof(OutlookEvent.Hours), active: (state) =>
                {
                    // 表示するかを検証
                    if (state.IsAllDay)
                        return false;
                    else
                        return true;
                })               
                .OnCompletion(processOutlookEventCreate)
                .Build();
        }        
    }
}

実行言語の設定

1. MessagesController.cs を開き、以下のコードに差し替え。

using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using System.Threading;
using System.Globalization;

namespace O365Bot
{
    [BotAuthentication]
    public class MessagesController : ApiController
    {
        /// <summary>
        /// POST: api/Messages
        /// Receive a message from a user and reply to it
        /// </summary>
        public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            // ロケールを取得して、現在のスレッドに設定
            var locale = string.IsNullOrEmpty(activity.Locale) ? "ja-JP" : activity.Locale;
            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);

            if (activity.Type == ActivityTypes.Message)
            {
                // 常に RootDialog を実行
                await Conversation.SendAsync(activity, () => new Dialogs.RootDialog());
            }
            else
            {
                HandleSystemMessage(activity);
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;
        }

        private Activity HandleSystemMessage(Activity message)
        {
            if (message.Type == ActivityTypes.DeleteUserData)
            {
                // Implement user deletion here
                // If we handle user deletion, return a real message
            }
            else if (message.Type == ActivityTypes.ConversationUpdate)
            {
                // Handle conversation state changes, like members being added and removed
                // Use Activity.MembersAdded and Activity.MembersRemoved and Activity.Action for info
                // Not available in all channels
            }
            else if (message.Type == ActivityTypes.ContactRelationUpdate)
            {
                // Handle add/remove from contact lists
                // Activity.From + Activity.Action represent what happened
            }
            else if (message.Type == ActivityTypes.Typing)
            {
                // Handle knowing tha the user is typing
            }
            else if (message.Type == ActivityTypes.Ping)
            {
            }

            return null;
        }
    }
}

エミュレーターでの検証

Locale を en-US で接続して add event を実行。フォームフローの Prompt はまだ多言語対応されていないですね。

image

ダイアログ、フォームフローのカスタムラベル

ダイアログの既定の文言は、多言語対応していますが、カスタムラベルは別途作業が必要です。

リソースファイルの生成

まずフォームフローで自動生成されるダイアログで使うリソースファイルを生成します。やり方はいくつかありますが、ここでは IFormBuilder.SaveResources を使うやり方を。

1. ソリューションに新しくコンソールアプリケーションプロジェクトを追加します。名前は O365Bot.ResourceGenerator としました。

2. NuGet の管理より BotBuilder を追加します。

3. 参照の追加より System.Windows.Forms.dll と O365Bot プロジェクトの参照を追加します。

4. Program.cs を以下に書き換えます。

using O365Bot.Dialogs;
using System.Resources;

namespace O365Bot.ResourceGenerator
{
    class Program
    {
        static void Main(string[] args)
        {
            // ファイル名はフォームフローで使うクラスの完全修飾名に、.ja.resx を追加したもの。
            ResXResourceWriter writer = new ResXResourceWriter("O365Bot.Models.OutlookEvent.ja.resx");
            CreateEventDialog.BuildOutlookEventForm().SaveResources(writer);
           
            writer.Generate();
        }
    }
}

5. スタートアッププロジェクトを O365Bot.ResourceGenerator にして、F5 で実行します。

リソースファイルの追加

1. ボットアプリプロジェクトの Resources フォルダを右クリックして、生成された O365Bot.Models.OutlookEvent.ja.resx を追加します。

image

2. 追加した resx ファイルをコピーして、名前を O365Bot.Models.OutlookEvent.resx にします。これは既定の言語用です。

3. O365Bot.Models.OutlookEvent.ja.resx を開いて中身を確認します。もともと Promt しか設定していないため、xx_promtpDefinition しか日本語になっていませんが、必要に応じて変更します。また FormBuilder 内の Message については、こちらにリソースが追加されるので、すでに用意したものは不要になります。

image

4. O365Bot.Models.OutlookEvent.resx を開いて、中身を英語にします。

image

エミュレーターでの検証

Locale を en-US で接続して add event を実行。

image

ja-JP の場合

image

多言語対応時のテスト

同じリソースファイルを使いまわすのが吉です。

1. ボットアプリの各リソースファイルを開き、アクセスを Public に変更します。これで他のプロジェクトからも参照できるようになります。

image

2. ユニットテストプロジェクトの UnitTest1.cs を以下に差し替えます。テスト側のロケールと、ボットに渡すロケールを揃えます。{||} のようなパターン言語の部分だけは差し替えが必要です。

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 O365Bot.Resources;
using System.Globalization;

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

        public SampleDialogTest()
        {            
            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"; };

                // サービスのモック
                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"
                        }
                    }
                });
                // IEventService 解決時にモックが返るよう設定
                var builder = new ContainerBuilder();
                builder.RegisterInstance(mockEventService.Object).As<IEventService>();
                WebApiApplication.Container = builder.Build();

                // テストしたいダイアログのインスタンス作成
                IDialog<object> rootDialog = new RootDialog();

                // Bot に送るメッセージを作成
                var toBot = DialogTestBase.MakeTestMessage();
                toBot.From.Id = Guid.NewGuid().ToString();
                // Locale 設定
                toBot.Locale = "ja-JP";
                toBot.Text = "get appointments";

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(rootDialog))
                using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog))
                {
                    // メッセージを送信して、結果を受信
                    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"; };

                // サービスのモック
                var mockEventService = new Mock<IEventService>();
                mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));

                // IEventService 解決時にモックが返るよう設定
                var builder = new ContainerBuilder();
                builder.RegisterInstance(mockEventService.Object).As<IEventService>();
                WebApiApplication.Container = builder.Build();

                // テストしたいダイアログのインスタンス作成
                IDialog<object> rootDialog = new RootDialog();
                              
                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(rootDialog))
                using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog))
                {
                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    // ロケールで日本語を指定
                    toBot.Locale = locale;
                    toBot.From.Id = Guid.NewGuid().ToString();
                    toBot.Text = "add appointment";

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

                    // 結果の検証
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST));
                    Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST));

                    toBot.Text = "件名";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST));

                    toBot.Text = "詳細";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST));

                    toBot.Text = "2017/06/06 13:00";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}","")));

                    toBot.Text = "はい";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].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"; };

                // サービスのモック
                var mockEventService = new Mock<IEventService>();
                mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true));

                // IEventService 解決時にモックが返るよう設定
                var builder = new ContainerBuilder();
                builder.RegisterInstance(mockEventService.Object).As<IEventService>();
                WebApiApplication.Container = builder.Build();

                // テストしたいダイアログのインスタンス作成
                IDialog<object> rootDialog = new RootDialog();

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(rootDialog))
                using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog))
                {
                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    // ロケールで日本語を指定
                    toBot.Locale = locale;
                    toBot.From.Id = Guid.NewGuid().ToString();
                    toBot.Text = "add appointment";

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

                    // 結果の検証
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST));
                    Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST));

                    toBot.Text = "件名";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST));

                    toBot.Text = "詳細";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST));

                    toBot.Text = "2017/06/06 13:00";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}","")));
                    
                    toBot.Text = "いいえ";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Hours_promptDefinition_LIST));


                    toBot.Text = "4";
                    toUser = await GetResponses(container, MakeRoot, toBot);
                    Assert.IsTrue(toUser[0].Text.Equals(O365BotLabel.Event_Created));
                }
            }
        }

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

3. ファンクションテストは、まずプロジェクトにボットアプリプロジェクトの参照を追加。

4. FunctionTest1.cs を以下に差し替え。

using System;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Bot.Connector.DirectLine;
using O365Bot.Resources;
using System.Globalization;
using System.Threading;
using Newtonsoft.Json;

namespace O365Bot.FunctionTests
{
    [TestClass]
    public class FunctionTest1
    {
        public FunctionTest1()
        {
            string locale = "ja-JP";
            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);
        }

        public TestContext TestContext { get; set; }

        [TestMethod]
        public void Function_ShouldReturnEvents()
        {
            DirectLineHelper helper = new DirectLineHelper(TestContext);
            var toUser = helper.SentMessage("get appointments");
            Assert.IsTrue(true);
        }

        [TestMethod]
        public void Function_ShouldCreateAllDayEvent()
        {
            DirectLineHelper helper = new DirectLineHelper(TestContext);
            var toUser = helper.SentMessage("add appointment");
            // 結果の検証
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST));
            Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST));

            toUser = helper.SentMessage("件名");
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST));

            toUser = helper.SentMessage("詳細");
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST));

            toUser = helper.SentMessage("2017/06/06 13:00");
            Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}", "")));

            toUser = helper.SentMessage("はい");
            Assert.IsTrue(toUser[0].Text.Equals(O365BotLabel.Event_Created));
        }

        [TestMethod]
        public void Function_ShouldCreateEvent()
        {
            DirectLineHelper helper = new DirectLineHelper(TestContext);
            var toUser = helper.SentMessage("add appointment");
            // 結果の検証
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST));
            Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST));

            toUser = helper.SentMessage("件名");
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST));

            toUser = helper.SentMessage("詳細");
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST));

            toUser = helper.SentMessage("2017/06/06 13:00");
            Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}", "")));

            toUser = helper.SentMessage("いいえ");
            Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Hours_promptDefinition_LIST));
            
            toUser = helper.SentMessage("4");
            Assert.IsTrue(toUser[0].Text.Equals(O365BotLabel.Event_Created));
        }
    }
}

チェックインしてテストが通るか確認。

まとめ

多言語対応初めにしておくと、後で楽になりますよ!次回はグローバルメニューについて紹介します。