今回はこれまで紹介したダイアログやフォームフローにも関係する、多言語対応について紹介します。
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 { [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() { var locale = "ja-JP"; Thread.CurrentThread.CurrentCulture = new CultureInfo(locale); Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale); // 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() { var locale = "ja-JP"; Thread.CurrentThread.CurrentCulture = new CultureInfo(locale); Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale); // 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; namespace O365Bot.FunctionTests { [TestClass] public class FunctionTest1 { 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((toUser[0].Attachments[0].Content as HeroCard).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}", ""))); toUser = helper.SentMessage("はい"); Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).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((toUser[0].Attachments[0].Content as HeroCard).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].Attachments[0].Content as HeroCard).Text.Equals(O365BotLabel.Event_Created)); } } }
チェックインしてテストが通るか確認。
まとめ
多言語対応初めにしておくと、後で楽になりますよ!次回はグローバルメニューについて紹介します。