Bot Builder v4 でボット開発 : 中断処理とグローバルコマンドのハンドリング

ここまでの記事では、開発者の意図通りにユーザーがボットを利用する前提でしたが、この記事ではそれ以外の処理について考えていきます。

# 中断処理とグローバルコマンド

**中断処理**
ユーザーがダイアログの途中で、処理を中断したいというメッセージを送ってくることがあります。これを中断処理と言います。中断処理は開発者があらかじめ予期できるもので、中断用のメニューを用意します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/67cad4b7-8051-5914-ca58-f3d94776bf04.png)

**グローバルコマンド**
ユーザーが任意のタイミングで、特定のダイアログではなく、全体に関わるようなメッセージを送ってくることがあります。これをグローバルコマンドと呼びます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7a3635ae-bba7-495e-b870-9fd0d1c9dd19.png)


# 中断処理の実装

まず中断処理から実装していきます。中断処理の実装をするには、ダイアログの流れ自体が中断に対応しやすい設計になっていることが重要です。今回は ProfileDialog を拡張して、最後の確認で名前や年齢を入れなおせるようにします。

1\. ProfileDialog.cs のクラスプロパティに Choice 用のリストを追加。

```csharp
private static IList<Choice> choices = ChoiceFactory.ToChoices(
    new List<string>() { "はい", "名前を変更する", "年齢を変更する" }
);
```

2\. コンストラクタに ChoicePrompt を追加。

```csharp
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"));
    AddDialog(new ChoicePrompt("choice"));
}
```

3\. NameConfirmStepAsync メソッドで既に年齢を取得しているか確認して、必要に応じて年齢取得をスキップするように変更。

- stepContext.ActiveDialog.State の stepIndex に現在のステップが入っている
- NextAsync を実行すると次のステップを実行
- NextAsync の引数には次ステップの実行に渡す値を指定

```csharp
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;

    // 年齢を既に聞いている場合
    if (userProfile.Age != 0)
    {
        // ウォーターフォールダイアログの 4 ステップ目を実行するため、
        // 3 ステップ目を設定してから NextAsync を実行。(0 が初めのステップ)
        // stepIndex には現在のステップが入る。
        stepContext.ActiveDialog.State["stepIndex"] = 2;
        // 次のステップ (4 ステップ目) に年齢を渡して実行
        return await stepContext.NextAsync(userProfile.Age);
    }

    // 年齢を聞いてもいいか確認のため "confirm" ダイアログを送信。
    return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text("年齢を聞いてもいいですか？") }, cancellationToken);
}
```

4\. ConfirmStepAsync メソッドで Confirm プロンプトを Choice プロンプトに差し替え。

```csharp
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;

    // 全て正しいか確認。"choice"　ダイアログを利用。
    // Choice プロンプトでメニューを表示
    return await stepContext.PromptAsync(
        "choice",
        new PromptOptions
        {
            Prompt = MessageFactory.Text($"次の情報で登録します。いいですか？{Environment.NewLine} 名前:{userProfile.Name} 年齢:{userProfile.Age}"),
            Choices = choices,
        },
        cancellationToken);
}
```

5\. SummaryStepAsync メソッドで Choice の結果によって処理を分岐。

```csharp
private async Task<DialogTurnResult> SummaryStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
    var userProfile = await accessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);

    var choice = (FoundChoice)stepContext.Result;
    switch (choice.Value)
    {
        case "名前を変更する":
            // 名前は初めに聞くのでダイアログごと Replace
            return await stepContext.ReplaceDialogAsync("profile");
        case "年齢を変更する":
            // 年齢の確認は 3 ステップ目
            stepContext.ActiveDialog.State["stepIndex"] = 1;
            // 前処理の結果として true の場合年齢を聞くので、true を渡す
            return await stepContext.NextAsync(true);
        case "はい":
        default:
            await stepContext.Context.SendActivityAsync(MessageFactory.Text("プロファイルを保存します。"));
            // ダイアログを終了
            return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
    }
}
```

6\. F5 で実行して、動作の確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/9dcbb151-bcd7-616b-0342-d6a63fbb013d.png)

これで意図された中断のサポートができました。

# グローバルコマンドの実装

次にグローバルコマンドを実装します。全てのメッセージはボットクラスの OnTurnAsync を通るため、そこで処理を実装することが一般的です。ここでは以下のコマンドを実装します。

- キャンセル : 現在のダイアログをキャンセル
- プロファイル変更 : プロファイルダイアログに入る

1\. まず WeatherDialog を拡張して、ユーザーとの対話を行うようにする。WeatherDialog.cs を以下のコードと差し替え。

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

public class WeatherDialog : ComponentDialog
{
    private static IList<Choice> choices = ChoiceFactory.ToChoices(new List<string>(){"今日","明日","明後日"});  
    public WeatherDialog() : base(nameof(WeatherDialog))
    {
        // ウォーターフォールのステップを定義。処理順にメソッドを追加。
        var waterfallSteps = new WaterfallStep[]
        {
            AskDateAsync,
            ShowWeatherAsync,
        };

        // ウォーターフォールダイアログと各種プロンプトを追加
        AddDialog(new WaterfallDialog("weather", waterfallSteps));
        AddDialog(new ChoicePrompt("choice"));
    }

    private static async Task<DialogTurnResult> AskDateAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        // Choice プロンプトでメニューを表示
        return await stepContext.PromptAsync(
            "choice",
            new PromptOptions
            {
                Prompt = MessageFactory.Text("いつの天気を知りたいですか？"),
                Choices = choices,
            },
            cancellationToken);
    }

    private static async Task<DialogTurnResult> ShowWeatherAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
    {
        var choice = (FoundChoice)stepContext.Result;
        await stepContext.Context.SendActivityAsync($"{choice.Value}の天気は晴れです");
        return await stepContext.EndDialogAsync(true, cancellationToken);
    }
}
```

2\. 次に MyBot.cs を書き換え、グローバルコマンドに対応。

- キャンセルでは CancelAllDialogsAsync で全ての処理をキャンセル 
- プロファイルの変更では、現在のダイアログスタックにプロファイルダイアログを追加

```csharp
using System;
using System.Linq;
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));
        dialogs.Add(new MenuDialog());
    }

    public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        // DialogSet からコンテキストを作成
        var dialogContext = await dialogs.CreateContextAsync(turnContext, cancellationToken);

        // ユーザーからメッセージが来た場合
        if (turnContext.Activity.Type == ActivityTypes.Message)
        {
            // Check for top-level interruptions.
            string utterance = turnContext.Activity.Text.Trim().ToLowerInvariant();

            if (utterance == "キャンセル")
            {
                // Cancel any dialog on the stack.
                await turnContext.SendActivityAsync("キャンセルします", cancellationToken: cancellationToken);
                await dialogContext.CancelAllDialogsAsync(cancellationToken);
                await dialogContext.BeginDialogAsync(nameof(MenuDialog), null, cancellationToken);
            }
            else if (utterance == "プロファイルの変更")
            {
                // Start a general help dialog. Dialogs already on the stack remain and will continue
                // normally if the help dialog exits normally.
                await dialogContext.BeginDialogAsync(nameof(ProfileDialog), null, cancellationToken);
            }
            else
            {
                // まず ContinueDialogAsync を実行して既存のダイアログがあれば継続実行。
                var results = await dialogContext.ContinueDialogAsync(cancellationToken);

                // DialogTurnStatus が Complete または Empty の場合、メニューへ。
                if (results.Status == DialogTurnStatus.Complete || results.Status == DialogTurnStatus.Empty)
                {
                    var userProfile = await accessors.UserProfile.GetAsync(turnContext, () => new UserProfile(), cancellationToken);
                    await turnContext.SendActivityAsync(MessageFactory.Text($"ようこそ '{userProfile.Name}' さん！"));
                    // メニューの表示
                    await dialogContext.BeginDialogAsync(nameof(MenuDialog), null, cancellationToken);
                }
            }
            // ユーザーに応答できなかった場合
            if (!turnContext.Responded)
            {
                await turnContext.SendActivityAsync("わかりませんでした。全てキャンセルします。", cancellationToken: cancellationToken);
                await dialogContext.CancelAllDialogsAsync(cancellationToken);
                await dialogContext.BeginDialogAsync(nameof(MenuDialog), null, cancellationToken);
            }
        }
        // ユーザーとボットが会話に参加した
        else if (turnContext.Activity.Type == ActivityTypes.ConversationUpdate)
        {

            // turnContext より Activity を取得
            var activity = turnContext.Activity.AsConversationUpdateActivity();
            // ユーザーの参加に対してだけ、プロファイルダイアログを開始
            if (activity.MembersAdded.Any(member => member.Id != activity.Recipient.Id))
            {
                var userProfile = await accessors.UserProfile.GetAsync(turnContext, () => new UserProfile(), cancellationToken);
                if (userProfile == null || string.IsNullOrEmpty(userProfile.Name))
                {
                    await turnContext.SendActivityAsync("ようこそ MyBot へ！");
                    await dialogContext.BeginDialogAsync(nameof(ProfileDialog), null, cancellationToken);
                }
                else
                {
                    await turnContext.SendActivityAsync(MessageFactory.Text($"ようこそ '{userProfile.Name}' さん！"));
                    // メニューの表示
                    await dialogContext.BeginDialogAsync(nameof(MenuDialog), null, cancellationToken);
                }
            }
        }
        
        // 最後に現在の UserProfile と DialogState を保存
        await accessors.UserState.SaveChangesAsync(turnContext, false, cancellationToken);
        await accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    }
}
```

3\. F5 でデバッグ実行して動作を確認。

**キャンセル**
天気の確認途中でキャンセルすると、ダイアログがすべて取り消しされメインに移動。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8c491dd5-8d65-c2c1-83cf-aa3cd73d85b8.png)

**プロファイルの変更**
天気の確認途中で、プロファイルダイアログに移動後、完了したら天気確認の続きから再実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2542a4f4-cf6b-9edd-7656-d9861c26ed4c.png)

# 既定の処理

通常のダイアログや中断処理、グローバルコマンドの処理でも対応できないものがあった場合、既定のダイアログを設定して、応答がないことを防ぎます。

```csharp
// ユーザーに応答できなかった場合
if (!turnContext.Responded)
{
    await turnContext.SendActivityAsync("わかりませんでした。全てキャンセルします。", cancellationToken: cancellationToken);
    await dialogContext.CancelAllDialogsAsync(cancellationToken);
    await dialogContext.BeginDialogAsync(nameof(MenuDialog), null, cancellationToken);
}
```
# まとめ

今回は中断処理とグローバルコマンドのハンドリング、そして最後に既定の応答の追加について見ていきました。次回はより柔軟なユーザー入力に対応できるよう、自然言語解析処理をボットに追加してみます。

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

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