Bot Framework と Microsoft Graph で DevOps その 8 : ダイアログ入門編

前回までで DevOps に必要な最低限の環境は整えました。今回から BotBuilder を深く。まずはダイアログ (Dialog) の入門編から。

ダイアログの概念

高度なボットはユーザーと会話することが必要です。つまり、ユーザーが言ったことを覚え、適切に次のアクションをとる必要があります。これ実装するのは結構骨なんですが、BotBuilder の Dialog 機能を使うと、とても簡単に実装ができます。通常 ダイアログは機能単位で分割して実装します。以下に本家の記事を引用して説明します。

引用元: https://docs.microsoft.com/ja-jp/bot-framework/bot-design-conversation-flow

以下の絵は通常のアプリとボットアプリの対比を示したもので、新しいオーダーから製品を探すフローを示しています。

bot

通常アプリはメイン画面が起動。そこからオーダー画面を起動した場合、明示的に閉じられるか処理が完了するま画面はそのままです。一方ボットの場合は全てがルートダイアログから始まり、そこから オーダーダイアログが起動されると会話を引き継ぎ、明示的に中断されるか処理が完了すると ルートダイアログに処理が戻ります。

ダイアログのスタックと実際のユーザーの相違

ユーザーが開発者の意図通りに、ダイアログを順次実行するとは限らず、気分や状況で返事してきます。例えば。。

bot

このようにボットアプリが予約を確定しようとしている段階で、「その映画何時だったかな?」と聞いてきたりします。この場合、以下のような処理が考えられます。

- ユーザーに、まず回答するように促す。
- これまでの処理をすべて破棄して、ユーザーの質問に回答する。
- ユーザーの質問に回答して、確認に戻る。

正確はなく、ユーザーシナリオやボットアプリに対する期待値によって解決策は変わります。

まぁ個人的には 3 つ目がいいですが。。

C# でのダイアログ実装

概念がわかったところで実装を。

RootDialog の実行

ボットアプリの実体はただの Web API で、常に MessagesController の Post メソッドにリクエストがきます。そこから Root Dialog を実行します。テンプレ通りです。

MessagesController.cs

 public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        // 常に RootDialog を実行
        await Conversation.SendAsync(activity, () => new Dialogs.RootDialog());
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

子ダイアログの呼び出し

RootDialog は実処理がなく、ユーザーの意図により子ダイアログの呼び出しをします。今のところ RootDialog に直接予定を取得するコードを書いているので、ここから直します。

1. Dialogs フォルダに新しいクラスとして GetEventsDialog.cs を追加。内容を以下と差し替え。

 using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using O365Bot.Services;
using System;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class GetEventsDialog : IDialog<bool> // このダイアログが完了時に返す型
    {
        public Task StartAsync(IDialogContext context)
        {
            context.Wait(MessageReceivedAsync);
            return Task.CompletedTask;
        }

        private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var message = await result as Activity;

            using (var scope = WebApiApplication.Container.BeginLifetimeScope())
            {
                // IEventService の実体を取得する
                // コンストラクタに IDialog context を渡す
                IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context));
                var events = await service.GetEvents();
                foreach (var @event in events)
                {
                    await context.PostAsync($"{@event.Start.DateTime}-{@event.End.DateTime}: {@event.Subject}");
                }
            }

            // ダイアログの完了を宣言
            context.Done(true);
        }        
    }
}

2. RootDialog.cs を以下のコードに差し替え。以下の箇所を変更。
- 予定取得を、GetEventsDialog を呼び出すように変更。
- 子ダイアログ完了時のメソッドを追加。
- 認証に行った場合は、元のメッセージを覚えて起き、後から復元し、すぐ処理に移行。
※ここでステートサービスというものを使っていますが、その説明はまた今度。

 using AuthBot;
using AuthBot.Dialogs;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using System;
using System.Configuration;
using System.Threading;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class RootDialog : IDialog<object>
    {
        public Task StartAsync(IDialogContext context)
        {
            context.Wait(MessageReceivedAsync);
            return Task.CompletedTask;
        }

        private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<object> result)
        {
            var message = await result as Activity;

            // 認証チェック
            if (string.IsNullOrEmpty(await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"])))
            {
                // 元のメッセージを記録
                context.PrivateConversationData.SetValue<Activity>("OriginalMessage", message as Activity);
                // 認証ダイアログの実行
                await context.Forward(new AzureAuthDialog(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]), this.ResumeAfterAuth, message, CancellationToken.None);
            }
            else
            {               
                await DoWork(context, message);
            }
        }

        private async Task DoWork(IDialogContext context, IMessageActivity message)
        {            
            // GetEventDialog を呼び出し、完了時に ResumeAfterDialog を実行
            await context.Forward(new GetEventsDialog(), ResumeAfterGetEventsDialog, message, CancellationToken.None);
        }

        private async Task ResumeAfterGetEventsDialog(IDialogContext context, IAwaitable<bool> result)
        {
            // ダイアログの実行結果
            var dialogResult = await result;
            // ルート処理に戻る
            context.Wait(MessageReceivedAsync);
        }

        private async Task ResumeAfterAuth(IDialogContext context, IAwaitable<string> result)
        {
            // 元のメッセージを復元
            var message = context.PrivateConversationData.GetValue<Activity>("OriginalMessage");
            await DoWork(context, message);
        }
    }
}

ダイアログの基本

以下、知っておくとより良い内容を説明。

Serialize 属性

BotBuilder はダイアログをシリアライズして管理しているため、ダイアログ自体がシリアライズできる必要があります。またメンバも同様です。これ付け忘れたり、メンバとして定義したクラスに Serialize 属性つけ忘れて落ちるのは”あるある”です。

IDiaglog<T> 継承

ダイアログは IDialog<T> を直接、または間接的に継承します。ここで T はダイアログ完了時の戻りの型です。結果はダイアログ完了時に呼ばれるメソッドの引数として渡されます。今回の場合 Boolean を戻すように指定しています。

StartAsync メソッド

ダイアログ開始時に呼ばれるメソッドです。ユーザーに対するアクションを取ります。内部処理で初期化したいものがあれば、通常のクラスと同じでコンストラクタを実装します。

context.Done<T> メソッド

ダイアログの処理が終わったことを宣言して、親ダイアログに処理を戻します。戻り値を引数に取ります。

まとめ

今回はダイアログ入門編ということで、簡単な説明しました。次回は応用編を紹介します。今回コードは変更しましたがユーザーから見た処理は同じです。コードはチェックインして、各種テストがパスすることを確認しておいてください。