Bot Builder v4 でボット開発 : より高度なダイアログ

前回は単純なダイアログを紹介しましたが、今回はより複雑なダイアログを見ていきます。

# ウォーターフォールダイアログ

複数のステップを順次処理する用のダイアログ。各ステップでは以下のような処理が行われる。

- プロンプトをユーザーに送信
- ユーザーからの入力を処理
- ウォーターフォール処理の完了

![waterfall](https://docs.microsoft.com/en-us/azure/bot-service/v4sdk/media/bot-builder-dialog-concept.png?view=azure-bot-service-4.0)

## ウォーターフォールステップ

ウォーターフォールダイアログはウォーターフォールステップと呼ばれるメソッドを実行順に持ちます。ウォーターフォールステップは以下のシグネチャを持っています。

```csharp
Task<DialogTurnResult> StepName(WaterfallStepContext stepContext, CancellationToken cancellationToken)
```
これらのステップを WaterfallStep 配列に設定して、ウォーターフォールダイアログを作成します。

```csharp
var waterfallSteps = new WaterfallStep[]
{
    Step1,
    Step2,
    Step3,
    ....
};

dialogs.Add(new WaterfallDialog("dialog_name", waterfallSteps));
```

## WaterfallStepContext

DialogContext を継承しており、前処理の結果などウォーターフォールダイアログ実行に必要な情報を含んでいます。

[GitHub : WaterfallStepContext](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/Microsoft.Bot.Builder.Dialogs/WaterfallStepContext.cs)

# コンポーネントダイアログ

再利用可能なダイアログを作成するための仕組みとして、コンポーネントダイアログが提供されています。DialogSet を独自に持てるため、ダイアログ名などの競合を避けることができる他、DialogContext も分離されます。

# ウォーターフォールダイアログを使ったボットの開発

前回は名前を聞くプロンプトダイアログを実装しましたが、今回はウォーターフォールダイアログを使って、ユーザープロファイルを聞くボットを実装します。

## モデルの追加とステート管理の更新

まずはユーザープロファイル用のクラス追加とアクセサーを更新します。

1\. Models フォルダを追加して、UserProfile.cs を追加。以下のコードを追加。

```csharp
public class UserProfile
{
    public string Name { get; set; }

    public int Age { get; set; }
}
```

2\. MyStateAccessors.cs ファイルを以下コードで差し替え。

```csharp
using System;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;

public class MyStateAccessors
{
        public MyStateAccessors(
            UserState userState,
            ConversationState conversationState)
        {
            UserState = userState ?? throw new ArgumentNullException(nameof(userState));
            ConversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
        }

        public IStatePropertyAccessor<UserProfile> UserProfile { get; set; }
        public IStatePropertyAccessor<DialogState> ConversationDialogState { get; set; }
        public UserState UserState { get; }
        public ConversationState ConversationState { get; }
}
```

3\. Startup.cs の ConfigureServices を以下の様に変更してアクセサーの変更を反映。

```csharp
public void ConfigureServices(IServiceCollection services)
{
    services.AddBot<MyBot>(options =>
    {
        options.Middleware.Add(new MyLoggingMiddleware());
        options.Middleware.Add(new MyMiddleware());

        // ストレージとしてインメモリを利用
        IStorage dataStore = new MemoryStorage();
        var userState = new UserState(dataStore);
        var conversationState = new ConversationState(dataStore);
        options.State.Add(userState);
        options.State.Add(conversationState);
    });

    // MyStateAccessors を IoC に登録
    services.AddSingleton(sp =>
    {
        // AddBot で登録した options を取得。
        var options = sp.GetRequiredService<IOptions<BotFrameworkOptions>>().Value;
        if (options == null)
        {
            throw new InvalidOperationException("BotFrameworkOptions を事前に構成してください。");
        }
        var userState = options.State.OfType<UserState>().FirstOrDefault();
        if (userState == null)
        {
            throw new InvalidOperationException("UserState を事前に定義してください。");
        }

        var conversationState = options.State.OfType<ConversationState>().FirstOrDefault();
        if (conversationState == null)
        {
            throw new InvalidOperationException("ConversationState を事前に定義してください。");
        }

        var accessors = new MyStateAccessors(userState, conversationState)
        {
            // DialogState を作成
            ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
            // UserProfile を作成
            UserProfile = userState.CreateProperty<UserProfile>("UserProfile")
        };

        return accessors;
    });
}
```

## ウォーターフォールダイアログの実装

次にウォーターフォールダイアログを使ったユーザープロファイル取得のコードを実装します。

1\. MyBot.cs の MyBot クラスに以下メソッドを追加。それぞれがウォーターフォールダイアログのステップとなる。

```csharp
private async Task<DialogTurnResult> NameStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // "name" ダイアログ(プロンプト) を返信。
    // ユーザーに対する表示は PromptOptions で指定。
    return await stepContext.PromptAsync("name", new PromptOptions { Prompt = MessageFactory.Text("名前を入力してください。") }, cancellationToken);
}

private async Task<DialogTurnResult> NameConfirmStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // UserProfile をステートより取得
    var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
    // Result より名前の更新
    userProfile.Name = (string)stepContext.Result;
    // 年齢を聞いてもいいか確認のため "confirm" ダイアログを送信。
    return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text("年齢を聞いてもいいですか？") }, cancellationToken);
}

private async Task<DialogTurnResult> AgeStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // Result より結果の確認
    if ((bool)stepContext.Result)
    {
        // 年齢を聞いてもいい場合は "age" ダイアログを送信
        return await stepContext.PromptAsync("age", new PromptOptions { Prompt = MessageFactory.Text("年齢を入力してください。") }, cancellationToken);
    }
    else
    {
        // "いいえ" を選択した場合、次のステップに進む。"age" ダイアログの結果は "-1" を指定。
        return await stepContext.NextAsync(-1, cancellationToken);
    }
}

private async Task<DialogTurnResult> ConfirmStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    // 年齢の回答を取得
    var age = (int)stepContext.Result;
    // UserProfile をステートより取得
    var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
    // 年齢をスキップしなかった場合はユーザープロファイルに設定
    if (age != -1)
        userProfile.Age = age;
    // 全て正しいか確認。"confirm"　ダイアログを再利用。
    var prompt = $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{userProfile.Name} 年齢:{userProfile.Age}";
    return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text(prompt) }, cancellationToken);
}

private async Task<DialogTurnResult> SummaryStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    if ((bool)stepContext.Result)
        await stepContext.Context.SendActivityAsync(MessageFactory.Text("プロファイルを保存します。"));
    else
        await stepContext.Context.SendActivityAsync(MessageFactory.Text("プロファイルを破棄します。"));

    // ダイアログを終了
    return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
}
```

2\. コンストラクターを変更してウォーターフォールダイアログを定義。

```csharp
public MyBot(MyStateAccessors accessors)
{
    this.accessors = accessors;
    this.dialogs = new DialogSet(accessors.ConversationDialogState);

    // ウォーターフォールのステップを定義。処理順にメソッドを追加。
    var waterfallSteps = new WaterfallStep[]
    {
        NameStepAsync,
        NameConfirmStepAsync,
        AgeStepAsync,
        ConfirmStepAsync,
        SummaryStepAsync,
    };

    // ウォーターフォールダイアログと各種プロンプトを追加
    dialogs.Add(new WaterfallDialog("profile", waterfallSteps));
    dialogs.Add(new TextPrompt("name"));
    dialogs.Add(new NumberPrompt<int>("age"));
    dialogs.Add(new ConfirmPrompt("confirm"));
}
```

3\. OnTurnAsync で profile ダイアログを実行するように変更。

```csharp
public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
{
    if (turnContext.Activity.Type == ActivityTypes.Message)
    {
        // DialogSet からコンテキストを作成
        var dialogContext = await dialogs.CreateContextAsync(turnContext, cancellationToken);
        // まず ContinueDialogAsync を実行して既存のダイアログがあれば継続実行。
        var results = await dialogContext.ContinueDialogAsync(cancellationToken);

        // DialogTurnStatus が Empty の場合は既存のダイアログがないため、新規に実行
        if (results.Status == DialogTurnStatus.Empty)
        {
            // ウォーターフォールダイアログを送信
            await dialogContext.BeginDialogAsync("profile", null, cancellationToken);
        }
        // DialogTurnStatus が Complete の場合、ダイアログは完了したため結果を処理
        else if (results.Status == DialogTurnStatus.Complete)
        {
            var userProfile = await accessors.UserProfile.GetAsync(turnContext);
            await turnContext.SendActivityAsync(MessageFactory.Text($"ようこそ '{userProfile.Name}' さん！"));
        }
        // 最後に現在の UserProfile と DialogState を保存
        await accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
        await accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    }
}
```

4\. デバッグを開始して BotFramework Emulator で動作を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c5ce7bc3-ae13-2811-33d7-e8d2f179d16e.png)

# コンポーネントダイアログを使ったボットの開発

ここでは、上記で実装したウォーターフォールダイアログをコンポーネントダイアログとして再利用しやすい形に変えてみます。

1\. ProfileDialog.cs を追加して、以下のコードと差し替え。内容は上記で実装したウォーターフォールダイアログとほぼ同じだが、ComponentDialog を継承しており、ダイアログ ID として nameof(ProfileDialog) を使用。また DialogSet は利用せず、AddDialog メソッドで各ダイアログを追加。

```csharp
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;

public class ProfileDialog : ComponentDialog
{
    private MyStateAccessors accessors;
  
    public ProfileDialog(MyStateAccessors accessors) : base(nameof(ProfileDialog))
    {
        this.accessors = accessors;
  
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            NameStepAsync,
            NameConfirmStepAsync,
            AgeStepAsync,
            ConfirmStepAsync,
            SummaryStepAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("profile", waterfallSteps));
        AddDialog(new TextPrompt("name"));
        AddDialog(new NumberPrompt<int>("age"));
        AddDialog(new ConfirmPrompt("confirm"));
    }

    private async Task<DialogTurnResult> NameStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // "name" ダイアログ(プロンプト) を返信。
        // ユーザーに対する表示は PromptOptions で指定。
        return await stepContext.PromptAsync("name", new PromptOptions { Prompt = MessageFactory.Text("名前を入力してください。") }, cancellationToken);
    }

    private async Task<DialogTurnResult> NameConfirmStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // UserProfile をステートより取得
        var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
        // Result より名前の更新
        userProfile.Name = (string)stepContext.Result;
        // 年齢を聞いてもいいか確認のため "confirm" ダイアログを送信。
        return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text("年齢を聞いてもいいですか？") }, cancellationToken);
    }

    private async Task<DialogTurnResult> AgeStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // Result より結果の確認
        if ((bool)stepContext.Result)
        {
            // 年齢を聞いてもいい場合は "age" ダイアログを送信
            return await stepContext.PromptAsync("age", new PromptOptions { Prompt = MessageFactory.Text("年齢を入力してください。") }, cancellationToken);
        }
        else
        {
            // "いいえ" を選択した場合、次のステップに進む。"age" ダイアログの結果は "-1" を指定。
            return await stepContext.NextAsync(-1, cancellationToken);
        }
    }

    private async Task<DialogTurnResult> ConfirmStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // 年齢の回答を取得
        var age = (int)stepContext.Result;
        // UserProfile をステートより取得
        var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
        // 年齢をスキップしなかった場合はユーザープロファイルに設定
        if (age != -1)
            userProfile.Age = age;
        // 全て正しいか確認。"confirm"　ダイアログを再利用。
        var prompt = $"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{userProfile.Name} 年齢:{userProfile.Age}";
        return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text(prompt) }, cancellationToken);
    }

    private async Task<DialogTurnResult> SummaryStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        if ((bool)stepContext.Result)
            await stepContext.Context.SendActivityAsync(MessageFactory.Text("プロファイルを保存します。"));
        else
            await stepContext.Context.SendActivityAsync(MessageFactory.Text("プロファイルを破棄します。"));

        // ダイアログを終了
        return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
    }
}
```

2\. MyBot.cs をコンポーネントダイアログを呼び出すように変更。

```csharp
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
public class MyBot : IBot
{
    private MyStateAccessors accessors;
    private DialogSet dialogs;
    // DI で MyStateAccessors は自動解決
    public MyBot(MyStateAccessors accessors)
    {
        this.accessors = accessors;
        this.dialogs = new DialogSet(accessors.ConversationDialogState);
       
        // コンポーネントダイアログを追加
        dialogs.Add(new ProfileDialog(accessors));      
    }

    public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        if (turnContext.Activity.Type == ActivityTypes.Message)
        {
            // DialogSet からコンテキストを作成
            var dialogContext = await dialogs.CreateContextAsync(turnContext, cancellationToken);
            // まず ContinueDialogAsync を実行して既存のダイアログがあれば継続実行。
            var results = await dialogContext.ContinueDialogAsync(cancellationToken);

            // DialogTurnStatus が Empty の場合は既存のダイアログがないため、新規に実行
            if (results.Status == DialogTurnStatus.Empty)
            {
                // コンポーネントダイアログを送信
                await dialogContext.BeginDialogAsync(nameof(ProfileDialog), null, cancellationToken);
            }
            // DialogTurnStatus が Complete の場合、ダイアログは完了したため結果を処理
            else if (results.Status == DialogTurnStatus.Complete)
            {
                var userProfile = await accessors.UserProfile.GetAsync(turnContext, () => new UserProfile(), cancellationToken);
                await turnContext.SendActivityAsync(MessageFactory.Text($"ようこそ '{userProfile.Name}' さん！"));
            }
            // 最後に現在の UserProfile と DialogState を保存
            await accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
            await accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
        }
    }    
}
```
3\. BotFramework Emulator で動作を確認。

# まとめ

今回はウォーターフォールダイアログとコンポーネントダイアログの使い方を見ていきました。またプロンプトの再利用シナリオもカバーしたため、より柔軟なダイアログを作れるようになりました。次回は複数ダイアログの管理について見ていきます。

[次の記事へ](https://qiita.com/kenakamu/items/3fbfac20a1954164e8fd)
[目次へ戻る](https://qiita.com/kenakamu/items/6dc043cfc1f199032883)

[この記事のサンプルコード](https://github.com/kenakamu/botbuilderv4completeguide/tree/article5)