Bot Framework と Microsoft Graph で DevOps その 9 : ダイアログ応用編
※ 2017/6/10 ファンクションテスト修正
前回はダイアログ入門編として基礎を紹介しました。今回はイベント (予定) の追加を実装し、その中で応用編としてダイアログの便利な機能を紹介します。
DialogPrompt
ボットアプリがユーザーと会話を行う際に、開発者は以下の点を考慮する必要があります。
- ユーザーに返信する内容: 単純なテキストか、ボタンなどよりリッチなコンテンツか。
- ユーザー入力の検証。数値や日付を期待している場合に、意図したものが返ってきたか。
- 意図しないものが返った場合のリトライ処理と文言の変更。
- リトライしてもダメだった場合の処理。
他にも色々ありますが、DialogPrompt を使えば上記のことは簡単に処理できます。また DialogPrompt は多言語に対応しており、Activity の Locale を設定すれば自動的に任意の言語になります。開発者が指定するメッセージの多言語化はまたの機会に。
イベント追加機能の実装 GraphService の変更
1. IEventService.cs にイベントを作成するメソッドを追加。
public interface IEventService
{
Task<List<Event>> GetEvents();
Task CreateEvent(Event @event);
}
2. 実体である GraphService.cs もイベントを作る部分を追加します。
public async Task CreateEvent(Event @event)
{
var client = await GetClient();
try
{
var events = await client.Me.Events.Request().AddAsync(@event);
}
catch (Exception ex)
{
}
}
CreateEventDialog.cs の追加
Dialogs フォルダに CreateEventDialog.cs を追加。中身を以下と差し替え。ここで様々な PromptDialog を利用していますが、基本的には大体同じで、ユーザーに返してほしい型の指定、返信時の文字列、リトライ催促時の文字列などを指定するだけです。結果はコールバックメソッドの引数に来ます。
using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Graph;
using O365Bot.Services;
using System;
using System.Globalization;
using System.Threading.Tasks;
namespace O365Bot.Dialogs
{
[Serializable]
public class CreateEventDialog : IDialog<bool> // このダイアログが完了時に返す型
{
private string subject;
private string detail;
private DateTime start;
private bool isAllDay;
private double hours;
public async Task StartAsync(IDialogContext context)
{
await context.PostAsync("イベントを作成します。");
// Text 入力を促す。
PromptDialog.Text(context, ResumeAfterTitle, "件名は?");
}
private async Task ResumeAfterTitle(IDialogContext context, IAwaitable<string> result)
{
subject = await result;
// Text 入力を促す。
PromptDialog.Text(context, ResumeAfterDetail, "詳細は?");
}
private async Task ResumeAfterDetail(IDialogContext context, IAwaitable<string> result)
{
detail = await result;
// 日付入力を促す機能はまだないので、Text 入力を促す。
PromptDialog.Text(context, ResumeAfterStard, "いつから?yyyy/MM/dd HH:mm 形式で入力してください。");
}
private async Task ResumeAfterStard(IDialogContext context, IAwaitable<string> result)
{
// 入力を検証して、だめならリトライ
if(!DateTime.TryParseExact(await result, "yyyy/MM/dd HH:mm", CultureInfo.CurrentCulture, DateTimeStyles.None, out start))
{
PromptDialog.Text(context, ResumeAfterStard, "日付がわかりませんでした。yyyy/MM/dd HH:mm 形式で入力してください。");
}
// はい、いいえを表示。別の回答を入力した場合、選択肢から入力するよう促してリトライ。
PromptDialog.Confirm(context, ResumeAfterIsAllDay, "終日イベント?", "選択肢から選択してください。");
}
private async Task ResumeAfterIsAllDay(IDialogContext context, IAwaitable<bool> result)
{
isAllDay = await result;
if (isAllDay)
await CreateEvent(context);
else
// 数字の入力を促す。数字以外が来た場合、リトライを促す。
PromptDialog.Number(context, ResumeAfterHours, "何時間?","数字で入力してください。");
}
private async Task ResumeAfterHours(IDialogContext context, IAwaitable<long> result)
{
hours = await result;
await CreateEvent(context);
}
private async Task CreateEvent(IDialogContext context)
{
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 = subject,
Start = new DateTimeTimeZone() { DateTime = start.ToString(), TimeZone = "Tokyo Standard Time" },
IsAllDay = isAllDay,
End = isAllDay ? null : new DateTimeTimeZone() { DateTime = start.AddHours(hours).ToString(), TimeZone = "Tokyo Standard Time" },
Body = new ItemBody() { Content = detail, ContentType = BodyType.Text }
};
await service.CreateEvent(@event);
await context.PostAsync("イベントを作成しました。");
}
// ダイアログの完了を宣言
context.Done(true);
}
}
}
RouteDialog.cs の変更
DoWork メソッドを以下のコードに差し替え。前回子ダイアログを Forward で呼び出しましたが、今回は Call で呼び出しています。Forward がユーザーインプットを子ダイアログに引き渡すのに対し、Call は引き渡しません。今回入力を引き渡すと、イベント作成の初めの処理である件名の値として、ユーザーの一言目が渡されるので、Call で呼び出しを実行。
private async Task DoWork(IDialogContext context, IMessageActivity message)
{
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);
}
テストの実装
ユニットテストの変更
1. UnitTest1.cs に以下のメソッドを追加。これで Bot からの応答が複数あった場合にも取得できます。
/// <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;
}
}
2. 以下 2 つのテストを追加。日付や他の場所でのリトライを検証したい場合は、それぞれテスト追加してください。
[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 = "ja-JP";
toBot.From.Id = Guid.NewGuid().ToString();
toBot.Text = "add appointment";
// メッセージを送信して、結果を受信
var toUser = await GetResponses(container, MakeRoot, toBot);
// 結果の検証
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成します。"));
Assert.IsTrue(toUser[1].Text.Equals("件名は?"));
toBot.Text = "件名";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("詳細は?"));
toBot.Text = "詳細";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("いつから?yyyy/MM/dd HH:mm 形式で入力してください。"));
toBot.Text = "2017/06/06 13:00";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals("終日イベント?"));
toBot.Text = "はい";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成しました。"));
}
}
}
[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 = "ja-JP";
toBot.From.Id = Guid.NewGuid().ToString();
toBot.Text = "add appointment";
// メッセージを送信して、結果を受信
var toUser = await GetResponses(container, MakeRoot, toBot);
// 結果の検証
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成します。"));
Assert.IsTrue(toUser[1].Text.Equals("件名は?"));
toBot.Text = "件名";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("詳細は?"));
toBot.Text = "詳細";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("いつから?yyyy/MM/dd HH:mm 形式で入力してください。"));
toBot.Text = "2017/06/06 13:00";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals("終日イベント?"));
toBot.Text = "いいえ";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("何時間?"));
toBot.Text = "4";
toUser = await GetResponses(container, MakeRoot, toBot);
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成しました。"));
}
}
}
ファンクションテストの変更
1. ロケール対応するために、DirectLineHelper.cs の SentMessage を以下に変更。
public List<Activity> SentMessage(string text, string locale = "ja-JP")
{
Activity activity = new Activity()
{
Type = ActivityTypes.Message,
From = new ChannelAccount(userId, userId),
Text = text,
Locale = locale
};
client.Conversations.PostActivity(conversationId, activity);
var reply = client.Conversations.GetActivities(conversationId, watermark);
watermark = reply.Watermark;
return reply.Activities.Where(x => x.From.Id != userId).ToList();
}
2. FunctionTest1.cs に以下 2 つのテストを追加。
[TestMethod]
public void Function_ShouldCreateAllDayEvent()
{
DirectLineHelper helper = new DirectLineHelper(TestContext);
var toUser = helper.SentMessage("add appointment");
// 結果の検証
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成します。"));
Assert.IsTrue(toUser[1].Text.Equals("件名は?"));
toUser = helper.SentMessage("件名");
Assert.IsTrue(toUser[0].Text.Equals("詳細は?"));
toUser = helper.SentMessage("詳細");
Assert.IsTrue(toUser[0].Text.Equals("いつから?yyyy/MM/dd HH:mm 形式で入力してください。"));
toUser = helper.SentMessage("2017/06/06 13:00");
Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals("終日イベント?"));
toUser = helper.SentMessage("はい");
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成しました。"));
}
[TestMethod]
public void Function_ShouldCreateEvent()
{
DirectLineHelper helper = new DirectLineHelper(TestContext);
var toUser = helper.SentMessage("add appointment");
// 結果の検証
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成します。"));
Assert.IsTrue(toUser[1].Text.Equals("件名は?"));
toUser = helper.SentMessage("件名");
Assert.IsTrue(toUser[0].Text.Equals("詳細は?"));
toUser = helper.SentMessage("詳細");
Assert.IsTrue(toUser[0].Text.Equals("いつから?yyyy/MM/dd HH:mm 形式で入力してください。"));
toUser = helper.SentMessage("2017/06/06 13:00");
Assert.IsTrue(JsonConvert.DeserializeObject<HeroCard>(toUser[0].Attachments[0].Content.ToString()).Text.Equals("終日イベント?"));
toUser = helper.SentMessage("いいえ");
Assert.IsTrue(toUser[0].Text.Equals("何時間?"));
toUser = helper.SentMessage("4");
Assert.IsTrue(toUser[0].Text.Equals("イベントを作成しました。"));
}
チェックインして、テストの確認。もしエミュレーターでテストする場合、Locale を ja-JP にしてください。
まとめ
DialogPrompt 便利です!ただ万能ではないので、状況に合わせた利用が必要となります。次回は FormFlow を紹介します。