かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Bot Builder SDK v4 勉強ログ

ドキュメント読みながらのメモ

Bot Builder SDK v4 の処理の流れ

こんな理解

ユーザーからのメッセージ from 各種クライアント(slack, teams, etc...) -> Bot Connector サービス -> Bot Builder SDK v4 製のアプリ

Bot Builder SDK v4 製のアプリがメッセージを受け取るとターンというのが始まり。これが 1 つのメッセージ(アクティビティ?)を処理する単位になる。 つまり、開発者は 1 ターンで何かして応答を返すという処理を書けばいい?

ターンにはターンコンテキストがある。ターン内で持ちまわりたい情報などを突っ込んでおける。

チャネルと Bot Connector サービス

Bot Connector サービスが仲介することで、Facebook や Skype や Slack や Teams なんかの間の差分を吸収してくれて Bot 開発者は Bot Builder SDK が提供するインターフェースに対してプログラムを書けばいい。幸せ。

ダイアログ

対話を管理するために重要層なものだけどコードの具体例を見てないので、まだイメージが出来ない。

WaterfallDialog という順次処理をやっていくようなものと組み込みの PromptDialog (Number, DateTime, etc...) があるみたい。

コードを見てみる

Bot Builder SDK v4 の Echo ボットのプロジェクトを新規作成してコードを真似ながら 1 からボットを作ってみる。

プロジェクトの作成

ASPNET Core Web アプリケーションを作る。テンプレートは空を選択。

以下の NuGet パッケージを追加します。

  • Microsoft.Bot.Builder v4.x
  • Microsoft.Bot.Builder.Integration.AspNet.Core v4.x
  • Microsoft.Bot.Configuration v4.x
  • Microsoft.Extensions.Logging.AzureAppServices v2.x

最後のは必須じゃないように感じるけど、ログは大事なので追加。

BotConfiguration.bot というファイルを追加。中身はただの JSON。

{
  "name": "EchoBotV4",
  "services": [
    {
      "type": "endpoint",
      "name": "development",
      "endpoint": "http://localhost:3978/api/messages",
      "appId": "",
      "appPassword": "",
      "id": "1"
    }
  ],
  "padlock": "",
  "version": "2.0"
}

このファイルをエミュレーターから読み込んだりする。なるほどね。ここにアプリの ID やらエンドポイントが書かれてるのでエミュレーターで都度入力しなくてもいい。

appsettings.json を作成する。ここにアプリの設定を書く。ファイル名は何でもいいけどまぁ。

{
  "botFilePath": "BotConfiguration.bot",
  "botFileSecret": ""
}

ここの botFilePath で設定した値と、さっき設定した BotConfiguration.bot の名前があってればいい。

ボットの作成

ボットは IBot インターフェースを実装して作成する。 OnTurnAsync があるだけのシンプルなインターフェース。

とりあえず turnContext.Activity.Type でアクティビティのタイプを取得して turnContext.Activity.Text で入力されたデータを取れるみたい。オウム返しだとこんな感じ。

using Microsoft.Bot.Builder;
using Microsoft.Bot.Schema;
using System.Threading;
using System.Threading.Tasks;

namespace MyBot
{
    public class MyBot : IBot
    {
        public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            if (turnContext.Activity.Type == ActivityTypes.Message)
            {
                await turnContext.SendActivityAsync($"「{turnContext.Activity.Text}」と言いましたね。");
            }
            else
            {
                await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
            }
        }
    }
}

スタートアップ処理の実装

あとは、このボットをスタートアップ時に登録すれば OK。エコーボットの雛形使うと、これに加えてステートを管理するためのクラスを作ってるので、もうちょっと長い。 あと Where(condition).FirstOrDefault() という処理してるのが気に入らないので FirstOrDefault(condition) に書き換えたり細かい変更はしてる。

using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Bot.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MyBot
{
    public class Startup
    {
        private bool IsProduction { get; }
        private IConfiguration Configuration { get; }
        private ILoggerFactory LoggerFactory { get; set; }

        public Startup(IHostingEnvironment env)
        {
            IsProduction = env.IsProduction();
            Configuration = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) // さっき作成した appsettings.json はここで読み込んでる
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) // appsettings.production.json とかがあれば読み込む
                .AddEnvironmentVariables() // Azure App Services にデプロイしたときにアプリケーション設定にある内容を読み込む
                .Build();
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddBot<MyBot>(options =>
            {
                var secretKey = Configuration.GetSection("botFileSecret")?.Value;
                var botFilePath = Configuration.GetSection("botFilePath")?.Value;
                // BotConfiguration.bot を読み込んでIServiceCollection に登録
                var botConfig = BotConfiguration.Load(botFilePath ?? @".\BotConfiguration.bot", secretKey);
                services.AddSingleton(_ => botConfig ?? throw new InvalidOperationException($"The .bot config file could not be loaded. ({botConfig})"));

                // オプション。ログとエラーハンドリング
                var logger = LoggerFactory.CreateLogger<MyBot>();
                options.OnTurnError = async (context, ex) =>
                {
                    logger.LogError($"Exception caught : {ex}");
                    await context.SendActivityAsync("Sorry, it looks like something went wrong.");
                };

                // BotConfiguration.bot から今の環境のエンドポイントの情報を取得。なかったらエラー。
                var environment = IsProduction ? "production" : "development";
                var endpointService = botConfig
                    .Services
                    .FirstOrDefault(x => x.Type == "endpoint" && x.Name == environment) as EndpointService ?? 
                        throw new InvalidOperationException($"The .bot file does not contain an endpoint with name '{environment}'.");
                // Bot の認証情報追加
                options.CredentialProvider = new SimpleCredentialProvider(endpointService.AppId, endpointService.AppPassword);

                // 開発用途限定!のインメモリーストア。本番は Blob とか使うらしい
                options.State.Add(new ConversationState(new MemoryStorage()));
            });
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            LoggerFactory = loggerFactory; // loggerFactory は必須じゃないけどログ大事なので書いてる

            // Bot Framework の有効化
            app.UseDefaultFiles()
                .UseStaticFiles()
                .UseBotFramework();
        }
    }
}

実行して確認

実行前にプロジェクトのプロパティーのデバッグでアプリ URL のポートを 3978 にしておきます。BotConfiguration.bot で 3978 って書いたので。 別に BotConfiguration.bot のほうのエンドポイントの URL をプロパティーのデバッグにあるアプリ URL のポート番号に合わせる形でも大丈夫だと思う。

編集したら実行しよう。

Bot Framework Emulator v4 (現時点でプレビュー) を立ち上げて File -> Open Bot Configuration で先ほど作った BotConfiguration.bot を開きます。 ENDPOINT に development というのが出ると思うので、そこで適当にメッセージを打ちます。

f:id:okazuki:20181022131755p:plain

ステート管理も追加

プロジェクトテンプレートで提供される Echo ボットと同じようにステート管理も追加してみます。 とりあえずボットで管理するステートを表すクラスを追加

namespace MyBot
{
    public class CounterState
    {
        public int Count { get; set; }
    }
}

プロジェクトテンプレートでは、なんか EcoBotAccessors.cs というクラスを作って、それを IServiceCollection に追加してます。 こうすることで複数のステータスをまとめる役割の人が出来るので最終的にはいいのでしょう。

ということで、それに従って MyBotAccessors クラスを作ります。これも雛形の作り方を見て、自分ならこうするなという変更を追加してます。 雛形のコードでは、CounterState の生成をあえてクラスの外でやってますが、これはクラス内で完結させたほうが綺麗な気がしたので。あと、ConversationState に依存してるけど、使ってる機能は、その親の抽象クラスの BotState の機能っぽいのでそっちの寄せました。その方がモックも作りやすそうだったし。理想的には、もう 1 つ上の IPropertyManager で済むと良かったのですが、ステートの保存が BotState クラスにあったので…。

ということで MyBotAccessors クラスはこんな感じ。

using Microsoft.Bot.Builder;
using System;
using System.Threading.Tasks;

namespace MyBot
{
    public class MyBotAccessors
    {
        private BotState BotState { get; }
        public IStatePropertyAccessor<CounterState> CounterState { get; }

        public MyBotAccessors(BotState botState)
        {
            BotState = botState ?? throw new ArgumentNullException(nameof(botState));
            CounterState = BotState.CreateProperty<CounterState>($"{nameof(MyBotAccessors)}.{nameof(CounterState)}");
        }

        public Task SaveChangesAsync(ITurnContext turnContext) => BotState.SaveChangesAsync(turnContext);
    }
}

これを IServiceCollection に追加するコードを Startup.cs の ConfigureServices メソッドに追加します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddBot<MyBot>(options =>
    {
        // 省略
    });

    // ステート管理の MyBotAccessors 追加
    services.AddSingleton(sp =>
    {
        var options = sp.GetRequiredService<IOptions<BotFrameworkOptions>>().Value ?? 
            throw new InvalidOperationException("BotFrameworkOptions must be configured prior to setting up the state accessors");
        var conversationState = options.State.OfType<ConversationState>().FirstOrDefault() ??
            throw new InvalidOperationException("ConversationState must be defined and added before adding conversation-scoped state accessors.");
        return new MyBotAccessors(conversationState);
    });
}

あとは MyBot に DI してやれば OK です。ついでにロガーも追加しました。

using Microsoft.Bot.Builder;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace MyBot
{
    public class MyBot : IBot
    {
        private ILogger Logger { get; }
        private MyBotAccessors MyBotAccessors { get; }

        public MyBot(MyBotAccessors myBotAccessors, ILoggerFactory loggerFactory)
        {
            MyBotAccessors = myBotAccessors ?? throw new ArgumentNullException(nameof(myBotAccessors));
            Logger = loggerFactory?.CreateLogger<MyBot>() ?? throw new ArgumentNullException(nameof(loggerFactory));
        }

        public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            Logger.LogInformation($"{nameof(OnTurnAsync)} started");
            if (turnContext.Activity.Type == ActivityTypes.Message)
            {
                var state = await MyBotAccessors.CounterState.GetAsync(turnContext, () => new CounterState());
                await turnContext.SendActivityAsync($"{state.Count++} 回目のやりとり。「{turnContext.Activity.Text}」と言いましたね。");
                await MyBotAccessors.CounterState.SetAsync(turnContext, state);
                await MyBotAccessors.SaveChangesAsync(turnContext);
            }
            else
            {
                await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
            }

            Logger.LogInformation($"{nameof(OnTurnAsync)} ended");
        }
    }
}

これで実行すると

f:id:okazuki:20181022134624p:plain

ばっちりですね。

ダイアログ対応

以下のドキュメントの内容を取り込んでみたいと思います。

docs.microsoft.com

まず、以下の NuGet パッケージを追加します。

  • Microsoft.Bot.Builder.Dialogs v4.x

ConversationState に DialogState という名前のステートが無いといけないみたいなので MyBotAccessors クラスに追加します。

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

namespace MyBot
{
    public class MyBotAccessors
    {
        private BotState ConversationState { get; }
        public IStatePropertyAccessor<CounterState> CounterState { get; }

        public IStatePropertyAccessor<DialogState> ConversationDialogState { get; }

        public MyBotAccessors(BotState conversationState)
        {
            ConversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
            CounterState = ConversationState.CreateProperty<CounterState>($"{nameof(MyBotAccessors)}.{nameof(CounterState)}");
            ConversationDialogState = ConversationState.CreateProperty<DialogState>($"{nameof(MyBotAccessors)}.{nameof(ConversationDialogState)}");
        }

        public Task SaveChangesAsync(ITurnContext turnContext) => ConversationState.SaveChangesAsync(turnContext);
    }
}

さらに UserState(ユーザー状態) といった新たなステートも存在することが前提になってるので追加します。因みに今ついかしてる ConversationState(会話状態) とはスコープが違うみたいですね。UserState のほうが長生き。

以下のドキュメントから引用します。

docs.microsoft.com

会話状態は、ボットがユーザーと交わしている現在の会話をボットが追跡するために役立ちます。 ボットがステップのシーケンスを完了する、または会話トピックを切り替える必要がある場合、会話プロパティを使用してシーケンス内のステップを管理、または現在のトピックを追跡することができます。

ユーザー状態は、ユーザーの以前の会話がどこで中断されたかを判断したり、戻ってきたユーザーにそのユーザーの名前を使って挨拶したりするなど、さまざまな目的で使用できます。 ユーザーの設定を保存すると、次回チャットするときにその情報を使用して会話をカスタマイズできます。 たとえば、ユーザーが関心を持っているトピックに関するニュース記事についてユーザーに通知したり、予約が利用可能になった場合にユーザーに通知したりできます。

ということで、Startup.cs の ConfigureServices メソッドで MyBot を登録するところで ConversationState を追加してるので、そこに UserState の追加もしておきます。

services.AddBot<MyBot>(options =>
{
    var secretKey = Configuration.GetSection("botFileSecret")?.Value;
    var botFilePath = Configuration.GetSection("botFilePath")?.Value;
    // BotConfiguration.bot を読み込んでIServiceCollection に登録
    var botConfig = BotConfiguration.Load(botFilePath ?? @".\BotConfiguration.bot", secretKey);
    services.AddSingleton(_ => botConfig ?? throw new InvalidOperationException($"The .bot config file could not be loaded. ({botConfig})"));

    // オプション。ログとエラーハンドリング
    var logger = LoggerFactory.CreateLogger<MyBot>();
    options.OnTurnError = async (context, ex) =>
    {
        logger.LogError($"Exception caught : {ex}");
        await context.SendActivityAsync("Sorry, it looks like something went wrong.");
    };

    // BotConfiguration.bot から今の環境のエンドポイントの情報を取得。なかったらエラー。
    var environment = IsProduction ? "production" : "development";
    var endpointService = botConfig
        .Services
        .FirstOrDefault(x => x.Type == "endpoint" && x.Name == environment) as EndpointService ?? 
            throw new InvalidOperationException($"The .bot file does not contain an endpoint with name '{environment}'.");
    // Bot の認証情報追加
    options.CredentialProvider = new SimpleCredentialProvider(endpointService.AppId, endpointService.AppPassword);

    // 開発用途限定!のインメモリーストア。本番は Blob とか使うらしい
    var storage = new MemoryStorage();
    options.State.Add(new ConversationState(storage));
    options.State.Add(new UserState(storage));
});

そして、MyBotAccessors.cs に UserState を受け取るように使用します。UserState に保存するデータは今回は以下のような UserProfile クラスにしました。

namespace MyBot
{
    public class UserProfile
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

そして MyBotAccessors.cs を以下のように編集します。

変更点としてはカウンターはもういらないので消しました。 ConversationState と UserState の渡し間違えが怖いので、型をそれぞれの型にしました。 保存処理は ConversationState のみの保存と UserState のみの保存と両方の保存のメソッドを作りました。 UserProfile 用のプロパティを作りました。

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

namespace MyBot
{
    public class MyBotAccessors
    {
        private ConversationState ConversationState { get; }
        private UserState UserState { get; }

        public IStatePropertyAccessor<DialogState> ConversationDialogState { get; }
        public IStatePropertyAccessor<UserProfile> UserProfile { get; }

        public MyBotAccessors(ConversationState conversationState, UserState userState)
        {
            ConversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
            UserState = userState ?? throw new ArgumentNullException(nameof(userState));
            ConversationDialogState = ConversationState.CreateProperty<DialogState>($"{nameof(MyBotAccessors)}.{nameof(ConversationDialogState)}");
            UserProfile = UserState.CreateProperty<UserProfile>($"{nameof(MyBotAccessors)}.{nameof(UserProfile)}");
        }

        public Task SaveConversationStateChangesAsync(ITurnContext turnContext) => ConversationState.SaveChangesAsync(turnContext);
        public Task SaveUserStateChangesAsync(ITurnContext turnContext) => UserState.SaveChangesAsync(turnContext);

        public async Task SaveChangesAsync(ITurnContext turnContext)
        {
            await SaveConversationStateChangesAsync(turnContext);
            await SaveUserStateChangesAsync(turnContext);
        }
    }
}

MyBotAccessors を IServiceCollection に追加してる Startup.cs の ConfigureServices の箇所を以下のように変更します。

// ステート管理の MyBotAccessors 追加
services.AddSingleton(sp =>
{
    var options = sp.GetRequiredService<IOptions<BotFrameworkOptions>>().Value ?? 
        throw new InvalidOperationException("BotFrameworkOptions must be configured prior to setting up the state accessors");
    var conversationState = options.State.OfType<ConversationState>().FirstOrDefault() ??
        throw new InvalidOperationException("ConversationState must be defined and added before adding conversation-scoped state accessors.");
    var userState = options.State.OfType<UserState>().FirstOrDefault() ??
        throw new InvalidOperationException("UserState must be defined and added before adding user-scoped state accessors.");
    return new MyBotAccessors(conversationState, userState);
});

あとは MyBot.cs を以下のように変更します。コンストラクタでダイアログを組み立てて OnTurnAsync でダイアログをキックしてます。 長いけど…。

using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace MyBot
{
    public class MyBot : IBot
    {
        private ILogger Logger { get; }
        private MyBotAccessors MyBotAccessors { get; }

        private DialogSet Dialogs { get; }

        public MyBot(MyBotAccessors myBotAccessors, ILoggerFactory loggerFactory)
        {
            MyBotAccessors = myBotAccessors ?? throw new ArgumentNullException(nameof(myBotAccessors));
            Logger = loggerFactory?.CreateLogger<MyBot>() ?? throw new ArgumentNullException(nameof(loggerFactory));

            Dialogs = new DialogSet(MyBotAccessors.ConversationDialogState);

            // WaterfallDialog を登録。引数に Task<DialogTurnResult> Xxx(WaterfallStepContext, CancellationToken) の配列を渡す。
            // 今回は一か所にまとめるためにラムダで書いたけど、普通は何らかのクラスのメソッドを渡すのがいいと思う。
            Dialogs.Add(new WaterfallDialog("details", new WaterfallStep[]
            {
                (stepContext, cancellationToken) => stepContext.PromptAsync("name", new PromptOptions { Prompt = MessageFactory.Text("名前は?") }, cancellationToken),
                async (stepContext, cancellationToken) =>
                {
                    var userProfile = await MyBotAccessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
                    userProfile.Name = (string)stepContext.Result;
                    await stepContext.Context.SendActivityAsync($"ありがと!{userProfile.Name}!!", cancellationToken: cancellationToken);
                    return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text("年齢教えてくれる?") }, cancellationToken);
                },
                async (stepContext, cancellationToken) =>
                {
                    if ((bool)stepContext.Result)
                    {
                        return await stepContext.PromptAsync("age", new PromptOptions {Prompt = MessageFactory.Text("ありがとう!年齢入れて!")}, cancellationToken);
                    }
                    else
                    {
                        // 年齢は -1 ということにして次のへ
                        return await stepContext.NextAsync(-1, cancellationToken);
                    }
                },
                async (stepContext, cancellationToken) =>
                {
                    var userProfile = await MyBotAccessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
                    userProfile.Age = (int)stepContext.Result;
                    if (userProfile.Age == -1)
                    {
                        // 年齢キャンセルされた
                        await stepContext.Context.SendActivityAsync($"ミステリアスなんだね!!", cancellationToken: cancellationToken);
                    }
                    else
                    {
                        // 年齢入れてもらった
                        await stepContext.Context.SendActivityAsync($"{userProfile.Age} 歳なんだね!!", cancellationToken: cancellationToken);
                   }

                    return await stepContext.PromptAsync("confirm", new PromptOptions { Prompt = MessageFactory.Text("あってる?") }, cancellationToken);
                },
                async (stepContext, cancellationToken) =>
                {
                    if ((bool)stepContext.Result)
                    {
                        var userProfile = await MyBotAccessors.UserProfile.GetAsync(stepContext.Context, () => new UserProfile(), cancellationToken);
                        if (userProfile.Age == -1)
                        {
                            await stepContext.Context.SendActivityAsync($"ミステリアスな {userProfile.Name} さんだね!", cancellationToken: cancellationToken);
                        }
                        else
                        {
                            await stepContext.Context.SendActivityAsync($"{userProfile.Age} 歳の {userProfile.Name} さんだね!", cancellationToken: cancellationToken);
                        }
                    }
                    else
                    {
                        await stepContext.Context.SendActivityAsync($"じゃぁ、君のことは覚えないで忘れておくね!", cancellationToken: cancellationToken);
                        await MyBotAccessors.UserProfile.DeleteAsync(stepContext.Context, cancellationToken);
                    }

                    return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
                }
            }));

            // WaterfallDialog の中で PromptAsync で呼び出してるダイアログも追加する。
            Dialogs.Add(new TextPrompt("name"));
            Dialogs.Add(new NumberPrompt<int>("age"));
            Dialogs.Add(new ConfirmPrompt("confirm"));
        }

        public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
            Logger.LogInformation($"{nameof(OnTurnAsync)} started");
            if (turnContext.Activity.Type == ActivityTypes.Message)
            {
                var dialogContext = await Dialogs.CreateContextAsync(turnContext, cancellationToken);
                var results = await dialogContext.ContinueDialogAsync(cancellationToken);
                if (results.Status == DialogTurnStatus.Empty)
                {
                    await dialogContext.BeginDialogAsync("details", null, cancellationToken);
                }

                await MyBotAccessors.SaveChangesAsync(turnContext);
            }
            else
            {
                await turnContext.SendActivityAsync($"{turnContext.Activity.Type} event detected");
            }

            Logger.LogInformation($"{nameof(OnTurnAsync)} ended");
        }
    }
}

実行するとちゃんとダイアログうまくいってそう。

f:id:okazuki:20181022145238p:plain

まとめ

プロジェクトテンプレートをスタート地点にするよりも、自分で書いてしまった方が余計なものがなくていいかも?う~ん。

ソース

以下の GitHub リポジトリに置いてます…が、リポジトリ作って push したのに 404 エラーになる。時間が解決してくれるのを祈ろう。

https://github.com/runceel/BotBuilderSDKv4HelloWorld