Bot Framework と Microsoft Graph で DevOps その 12 : ボットアプリとテストの多言語対応
※2017/6/10 ユニットテストとファンクションテストのコードを修正。
今回はこれまで紹介したダイアログやフォームフローにも関係する、多言語対応について紹介します。
Activity の Locale プロパティ
Web API に送られてくる Activity には Locale プロパティがあります。ダイアログやフォームフローで作成されたダイアログはこの値をみて、動的に言語を切り替えます。ただすべてのチャネルで Locale セットしてくれるわけではありません。またダイアログ以外の独自のラベルは、別途多言語対応する必要があります。
エミュレーターでの言語指定
言語を接続時に指定できます。
多言語対応
リソースファイル
ボットアプリはただの Web API のため、resx ファイルを使った多言語対応が可能です。
1. ボットアプリプロジェクトに Resources フォルダを追加。
2. 新しいアイテムとしてリソースファイルを追加。名前は O365BotLabel.resx としました。これが既定の言語リソースになります。
3. Create_Event を追加し、値として Creating an event. と設定。
4. 次に同じフォルダ内に、O365BotLabel.ja.resx リソースファイルを追加。以下のように同じリソースを追加。
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 はまだ多言語対応されていないですね。
ダイアログ、フォームフローのカスタムラベル
ダイアログの既定の文言は、多言語対応していますが、カスタムラベルは別途作業が必要です。
リソースファイルの生成
まずフォームフローで自動生成されるダイアログで使うリソースファイルを生成します。やり方はいくつかありますが、ここでは 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 を追加します。
2. 追加した resx ファイルをコピーして、名前を O365Bot.Models.OutlookEvent.resx にします。これは既定の言語用です。
3. O365Bot.Models.OutlookEvent.ja.resx を開いて中身を確認します。もともと Promt しか設定していないため、xx_promtpDefinition しか日本語になっていませんが、必要に応じて変更します。また FormBuilder 内の Message については、こちらにリソースが追加されるので、すでに用意したものは不要になります。
4. O365Bot.Models.OutlookEvent.resx を開いて、中身を英語にします。
エミュレーターでの検証
Locale を en-US で接続して add event を実行。
ja-JP の場合
多言語対応時のテスト
同じリソースファイルを使いまわすのが吉です。
1. ボットアプリの各リソースファイルを開き、アクセスを Public に変更します。これで他のプロジェクトからも参照できるようになります。
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));
}
}
}
チェックインしてテストが通るか確認。
まとめ
多言語対応初めにしておくと、後で楽になりますよ!次回はグローバルメニューについて紹介します。