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 を紹介します。