Bot Builder v4 でボット開発 : 単純なダイアログとステート管理

本記事では Bot Builder v4 のダイアログとステート管理について見ていきます。ダイアログについては、まずは単純なものを見ていき、今後の記事でより複雑なダイアログを見ていきます。

# ダイアログ

ダイアログは Bot Builder でユーザーとボットの会話を管理する際に中心となる概念です。ダイアログはユーザーからの入力を処理して、次の出力を生成し、その過程を管理します。

例えばユーザープロファイルを作る場面で、ボットは名前、誕生日、仕事など複数の質問を行い、対応するユーザーの回答を記憶、最後にデータベースなどに情報を格納するという動作を行います。この際、以下のような内容を管理する必要があります。

- ユーザーの入力が、どの質問に対応するものかを理解
- ユーザー入力の検証と記憶
- 次の質問を正しく選択して、ユーザーへ送信
- 最後の質問に対する回答を得た場合、データベースの保存

Bot Builder ではダイアログクラスが以下の様に用意されています。

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

## プロンプト

プロンプトは最小単位のダイアログであり、ユーザーからの入力に対して型チェックを行う機能を提供します。

- テキスト型
- 数値型
- ブール型
- リストからの選択肢
- 添付ファイル
- 日付型

またプロンプトは以下の性質があります。

- 入力データの型を確認するだけで、ユーザーに送る文字列は別途指定
- 上記理由からプロンプト単体は再利用が可能
- ユーザーに対する送信と、ユーザーからの入力の２ステップで完結するため、ステート管理が必須

## DialogSet

複数のダイアログをまとめて管理する機能として DialogSet があります。ダイアログの状態を管理するためには、DialogSet を、後述する DialogState を引数に作成して利用します。

以下の場合は、3 つのプロンプトダイアログを、それぞれ name, age, birthday として追加しています。

```csharp
DialogSet dialogs = new DialogSet(accessors.ConversationDialogState);
dialogs.Add(new TextPrompt("name"));
dialogs.Add(new NumberPrompt("age"));
dialogs.Add(new DatePrompt("birthday"));
```
追加の順番と実行の順番には関係がなく、取り出す際には指定した名前 (id) を使います。

## DialogContext とダイアログの実行

ダイアログの実行に必要な情報を持ったコンテキストで、DialogSet に TurnContext を渡して作成します。また通常のターンに該当するもので DialogTurn という概念があり、ダイアログ全体の処理を 1 ターンをして捉えます。

```csharp
var dialogContext = await dialogs.CreateContextAsync(turnContext, cancellationToken);
```
また DialogContext は以下のダイアログ実行メソッドをサポートします。

- BeginDialogAsync
- ContinueDialogAsync
- ResumeDialogAsync
- PromptAsync

### ContinueDialogAsync

実装パターンとして、まず ContinueDialogAsync メソッドを実行します。既に開始しているダイアログがある場合は継続して実行され、ない場合は DialogTurnStatus.Empty が返ってきます。

```csharp
var results = await dialogContext.ContinueDialogAsync(cancellationToken);
```
### BeginDialogAsync

DialogTurnStatus.Empty が返ってきた場合は、新規にダイアログを開始するため、BeginDialogAsync を実行します。

```csharp
await dialogContext.BeginDialogAsync("name",
    // ユーザーに対する表示
    new PromptOptions { Prompt = MessageFactory.Text("名前を入力してください") }, 
    cancellationToken);
```

### ResumeDialogAsync

ネストされたダイアログを利用する場合、子ダイアログが終わった際、親ダイアログで呼ばれます。今後の記事で詳細は紹介します。

### PromptAsync

BeginDialogAsync のラッパー

[GitHub より引用 : PromptAsync](https://github.com/Microsoft/botbuilder-dotnet/blob/dfae1bc409d310dd459337aca2f75072680ac208/libraries/Microsoft.Bot.Builder.Dialogs/DialogContext.cs#L95)

```csharp
public async Task<DialogTurnResult> PromptAsync(string dialogId, PromptOptions options, CancellationToken cancellationToken = default(CancellationToken))
{
    if (string.IsNullOrEmpty(dialogId))
    {
        throw new ArgumentNullException(nameof(dialogId));
    }

    if (options == null)
    {
        throw new ArgumentNullException(nameof(options));
    }

    return await BeginDialogAsync(dialogId, options, cancellationToken).ConfigureAwait(false);
}
```

# ステート管理

ダイアログが成立するためには、会話の状態を保存するためのステートを管理する必要があります。Bot Builder では以下 2 種類のステートを定義しています。

- UserState : ユーザー単位のステート (複数の会話にまたがるステート)
- ConversationState : 会話単位のステート (特定の会話に関係するステート)

ステートにはシリアライズできるオブジェクトを保存します。またデータの保存先として、SDK レベルで以下の 3 パターンをサポートします。

- インメモリ (開発用)
- Azure テーブル
- Azure Cosmos DB

Azure のデータストアについては **Microsoft.Bot.Builder.Azure** モジュールで提供されます。

## ダイアログステート

ダイアログのステートは ConversationState に DialogState 型として保存します。

## アクセサー

ステートにデータを保存したり、保存済のデータを取得するには、IStatePropertyAccessor<T> を使います。インターフェースでは Get/Set/Delete のメソッドがそれぞれ定義されていて、容易に情報を扱うことが可能です。BotBuilder ではアクセサー専用の UserState、ConversationState および IStatePropertyAccessor<T> を持つクラスを定義して、IoC コンテナに登録して使うパターンがよく使われます。

以下の開発でも、このパターンを使った実装を行います。

# プロンプトを使ったボットの開発

ここでは前回開発した「オウム返し」ボットを拡張して、名前を聞くボットを実装します。また現在の会話において、ユーザーとやり取りした回数を数える機能を追加します。

## NuGet パッケージの追加

ダイアログ機能は **Microsoft.Bot.Builder.Dialogs** で提供されるため、VSCode の統合ターミナルより以下コマンドを実行。

```shell
dotnet add package Microsoft.Bot.Builder.Dialogs
dotnet restore
```

## 開発

1\. MyStateAccessors.cs ファイルを追加し、クラス定義を追加。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8b57adc5-bf9b-d947-7477-f13bd6862a0c.png)

2\. コードを以下のものと差し替え。クラスに ConversationState プロパティを持ち、またダイアログを記録するプロパティを IStatePropertyAccessor<DialogState> として定義。

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

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

        public IStatePropertyAccessor<DialogState> ConversationDialogState { get; set; }
        public ConversationState ConversationState { get; }
}
```
3\. Startup.cs の ConfigurationServices に記述した AddBot を以下の様に変更。

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

    // ストレージとしてインメモリを利用
    IStorage dataStore = new MemoryStorage();
    // それぞれのステートを作成
    var conversationState = new ConversationState(dataStore);
    // オプションに追加
    options.State.Add(conversationState);
});
```

4\. 同じく ConfigurationServices  メソッド内で MyStateAccessors を IoC に登録。これで動的に MyStateAccessors を取得可能。

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

    var accessors = new MyStateAccessors(conversationState)
    {
        // DialogState を ConversationState のプロパティとして設定
        ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
    };

    return accessors;
});
```

5\. MyBot.cs に using を追加。

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

6\. コンストラクターとクラスプロパティを以下の様に変更。引数として指定した MyStateAccessors は DI として自動解決される。

```csharp
private MyStateAccessors accessors;
private DialogSet dialogs;

public MyBot(MyStateAccessors accessors)
{
    this.accessors = accessors;
    this.dialogs = new DialogSet(accessors.ConversationDialogState);
    // テキスト型のプロンプトとして id=name で作成
    dialogs.Add(new TextPrompt("name"));
}
```
7\. OnTurnAsync メソッドを以下の様に変更。ユーザーに対するプロンプトの表示は、MessageFactory を使用。

```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)
        {
            // id=name のプロンプトを送信
            await dialogContext.PromptAsync(
                "name",
                new PromptOptions { Prompt = MessageFactory.Text("名前を入力してください") }, // ユーザーに対する表示
                cancellationToken);
        }
        // DialogTurnStatus が Complete の場合、ダイアログは完了したため結果を処理
        else if (results.Status == DialogTurnStatus.Complete)
        {
            if (results.Result != null)
            {
                await turnContext.SendActivityAsync(MessageFactory.Text($"ようこそ '{results.Result}' さん！"));
            }
        }
        // 最後に現在のダイアログステートを保存
        await accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    }
}
```

8\. デバッグ実行して、Bot Framework Emulator で動作を確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a30de650-fc01-fe86-e70e-3cba70302461.png)

# まとめ

今回はまず単純なダイアログとステートの管理について見ていきました。一番シンプルに名前を聞くだけでも、ボットしてはステート管理する必要がありますが、BotBuilder を使うと簡単に実装出来ます。次回はより高度なダイアログを見ていきます。

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

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