かずきのBlog@hatena

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

流行のボットをMicrosoft Bot Framework(.NET SDK)で作ってみよう

MicrosoftのBot作成用FrameworkのBot Framework入門してみましょう。公式サイトは以下になります。

Bot Framework

導入

導入は至って簡単です。公式の導入手順は以下です。

Getting started with the Connector | Bot Builder SDK C# Reference Library | Bot Framework

以下に手順を示します。

プロジェクトテンプレートのインストール

Bot Framework用のC#のプロジェクトテンプレートが以下からダウンロードできます。

http://aka.ms/bf-bc-vstemplate

zipファイルをダウンロードしたら、ドキュメントフォルダの下のVisual Studio 2015\Templates\ProjectTemplates\Visual C#にコピーします。

エミュレータのダウンロード

デバッグに便利なエミュレータも入れておきます。以下からインストールできます。

Bot Framework Channel Emulator

Hello world

ではHello worldをしてみます。BotのHello worldということで、名前を入力したら「こんにちは〇〇さん」と表示するBotでも作ってみようと思います。

プロジェクトの新規作成からBot Applicationを選択します。プロジェクト名はHelloBotAppにしました。

f:id:okazuki:20160927200650p:plain

とりあえず、エミュレータの動作を見るため、このままF5を押して実行します。以下のような画面が出れば起動は完了です。

f:id:okazuki:20160927200949p:plain

URLを控えておいてエミュレータを起動しましょう。赤線の部分のURLをhttp://localhost:起動したポート番号/api/messagesと入力します。(Bot Applicationのプロジェクトテンプレートはデフォルトで3979番ポートを使っていて、エミュレータはデフォルトで、そのポート番号を指定されてると思うので、何もしてなければそのままでいいと思います)

f:id:okazuki:20160927201242p:plain

画面下部の入力欄に「こんにちは」と打ち込んでEnterを押します。するとボットにメッセージが送られて「You sent こんにちは which was 5 characters」というレスポンスが返ってきます。メッセージを選択すると、そのときのJSONも見ることができます。

f:id:okazuki:20160927201443p:plain

では、プログラムを見ていきます。ControllersフォルダにMessagesControllerがいます。これがボットの本体です。コードは以下のようになっています。

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;
using Microsoft.Bot.Connector;
using Newtonsoft.Json;

namespace HelloBotApp
{
    [BotAuthentication]
    public class MessagesController : ApiController
    {
        /// <summary>
        /// POST: api/Messages
        /// Receive a message from a user and reply to it
        /// </summary>
        public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            if (activity.Type == ActivityTypes.Message)
            {
                ConnectorClient connector = new ConnectorClient(new Uri(activity.ServiceUrl));
                // calculate something for us to return
                int length = (activity.Text ?? string.Empty).Length;

                // return our reply to the user
                Activity reply = activity.CreateReply($"You sent {activity.Text} which was {length} characters");
                await connector.Conversations.ReplyToActivityAsync(reply);
            }
            else
            {
                HandleSystemMessage(activity);
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;
        }

        private Activity HandleSystemMessage(Activity message)
        {
            if (message.Type == ActivityTypes.DeleteUserData)
            {
                // Implement user deletion here
                // If we handle user deletion, return a real message
            }
            else if (message.Type == ActivityTypes.ConversationUpdate)
            {
                // Handle conversation state changes, like members being added and removed
                // Use Activity.MembersAdded and Activity.MembersRemoved and Activity.Action for info
                // Not available in all channels
            }
            else if (message.Type == ActivityTypes.ContactRelationUpdate)
            {
                // Handle add/remove from contact lists
                // Activity.From + Activity.Action represent what happened
            }
            else if (message.Type == ActivityTypes.Typing)
            {
                // Handle knowing tha the user is typing
            }
            else if (message.Type == ActivityTypes.Ping)
            {
            }

            return null;
        }
    }
}

見てわかる通り、ボット用の認証属性のついた、ただのWebAPIです。その中でBot FrameworkのAPIを呼び出して色々やります。

PostメソッドのわたってきているActivityがいろいろな情報が詰まっています。そのTypeMessageの時にチャットの応答を返せばOKです。デフォルトでは入力文字列(ActivityTextプロパティで取得可能)の長さを算出してレスポンスを返しています。

ConnectorClientがクライアントとの接続を表していて、そこのConversationsプロパティのReplyToActivityAsyncActivityCreateReplyメソッドで作れるActivityを渡すことで返事を返せます。 ということで、最初に書いた通り名前を入力してもらうとして「こんにちは〇〇さん」と返すようにしましょう。

以下のようなPostメソッドになります。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        var connector = new ConnectorClient(new Uri(activity.ServiceUrl));
        var reply = activity.CreateReply($"こんにちは{activity.Text}さん");
        await connector.Conversations.ReplyToActivityAsync(reply);
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

実行してエミュレータに、名前を入力して送ってみましょう。以下のようになります。

f:id:okazuki:20160927202227p:plain

ダイアログ

次に、よく使うと思われるダイアログについて説明します。ダイアログは簡単に言ってしまえばGUIで言うところのウィザード形式のダイアログみたいなものです。IDialog<T>インターフェースを実装したクラスがダイアログになります。簡単に名前を入力するダイアログを作ってみようと思います。

DialogAppという名前でプロジェクトを作ります。Dialogsフォルダを作成して、そこにInputNameDialogクラスを作成します。ダイアログはIDialogインターフェースを実装します。IDialogインターフェースはStartAsyncメソッドがあるので、それを実装します。StartAsyncメソッドの引数のIDialogContextがダイアログの処理が色々詰まっています。あと、ダイアログはシリアル化可能でなければならないという制約があるのでSerializable属性をつけておきます。

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

namespace DialogApp.Dialogs
{
    [Serializable]
    public class InputNameDialog : IDialog
    {
        public Task StartAsync(IDialogContext context)
        {
            
        }
    }
}

では、StartAsyncを実装していきます。ここでも名前を入力したら「こんにちは〇〇さん」と出すだけのダイアログを作りたいと思います。ダイアログの作り方は基本的にcontextWaitメソッドにメッセージを処理するメソッドを渡す形で定義します。こんな感じになります。

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

namespace DialogApp.Dialogs
{
    public class InputNameDialog : IDialog
    {
        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.ReceiveMessageAsync);
            return Task.CompletedTask;
        }

        private Task ReceiveMessageAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            throw new NotImplementedException();
        }
    }
}

ReceiveMessageAsyncメソッドに処理を書いていきます。基本的には第二引数をawaitしてメッセージの入ったIMessageActivityを取得して、そのTextプロパティを見て処理を行います。レスポンスの返し方は、IDialogContextPostAsyncというメソッドがあるので、そこに文字列を渡せばOKです。最初より簡単ですね。ということでコードは以下のようになります。

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

namespace DialogApp.Dialogs
{
    [Serializable]
    public class InputNameDialog : IDialog
    {
        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.ReceiveMessageAsync);
            return Task.CompletedTask;
        }

        private async Task ReceiveMessageAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var activity = await result;
            await context.PostAsync($"こんにちは{activity.Text}さん");
        }
    }
}

ではMessagesControllerでダイアログを使うように変更します。ConversationクラスのSendAsyncを使うことで出来ます。SendAsyncは第一引数にActivityを受け取り、第二引数にダイアログを生成するデリゲートを受け取ります。ということで、先ほど作成したInputNameDialogを使うようにするコードは以下のようになります。

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

実行結果は先ほどと一緒になります。

複数の値を入力させてみよう

ダイアログはウィザードみたいなものだと言いました。ウィザードといえば複数の入力値を受け付けるのが一般的だと思います。ダイアログを改造して名前と年齢を受け取るようにしたいと思います。やり方は簡単で1回のやり取りが終わったら次の入力処理をcontext.Waitで渡してやればいいだけです。あと、一連のダイアログの処理が終わったらcontext.Doneを呼んでやる感じでいけます。コードは以下のようになります。

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

namespace DialogApp.Dialogs
{
    [Serializable]
    public class InputNameDialog : IDialog
    {
        public string Name { get; set; }
        public int Age { get; set; }

        public async Task StartAsync(IDialogContext context)
        {
            context.Wait(this.InputNameAsync);
        }

        private async Task InputNameAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var activity = await result;
            this.Name = activity.Text;
            await context.PostAsync($"こんにちは{this.Name}さん。続けて年齢を入力してください");
            context.Wait(this.InputAgeAsync);
        }

        private async Task InputAgeAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var activity = await result;
            int age;
            if (int.TryParse(activity.Text ?? "", out age))
            {
                this.Age = age;
                await context.PostAsync($"こんにちは{this.Age}歳の{this.Name}さん");
                context.Done((object)null);
            }
            else
            {
                await context.PostAsync($"年齢は数字で入力してください");
                context.Wait(this.InputAgeAsync);
            }
        }
    }
}

実行すると以下のような感じになります。

f:id:okazuki:20160927205629p:plain

ダイアログの連携

ウィザードといえば一連の入力したデータが終わったら後続の処理を実行するようなイメージがあります。Bot Frameworkにも、そんな感じの機能があります。Chainというものを使ってダイアログを連結することで実現できます。ダイアログ間の値の受け渡しは、context.Doneメソッドで渡したものが後続に渡るイメージです。

ということで値受け渡し用のデータの入れ物のPersonクラスを用意します。これもSerializableにしておきます。

using System;

namespace DialogApp.Models
{
    [Serializable]
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

そして、InputPersonInfoDialogというクラスを作成して以下のように実装します。結果を返すダイアログはIDialog<T>を実装する点がこれまでのダイアログと異なっています。

using DialogApp.Models;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using System;
using System.Threading.Tasks;

namespace DialogApp.Dialogs
{
    [Serializable]
    public class InputPersonInfoDialog : IDialog<Person>
    {
        private Person Person { get; set; } = new Person();

        public Task StartAsync(IDialogContext context)
        {
            context.Wait(this.InputNameAsync);
            return Task.CompletedTask;
        }

        private async Task InputNameAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var activity = await result;
            this.Person.Name = activity.Text;
            await context.PostAsync($"こんにちは{this.Person.Name}さん。続けて年齢を入力してください");
            context.Wait(this.InputAgeAsync);
        }

        private async Task InputAgeAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var activity = await result;
            int age;
            if (int.TryParse(activity.Text ?? "", out age))
            {
                this.Person.Age = age;
                context.Done(this.Person);
            }
            else
            {
                await context.PostAsync("年齢は数字で入れてください");
                context.Wait(this.InputAgeAsync);
            }
        }
    }
}

後続の処理を行うダイアログを作成します。といっても入力された値をダンプするだけのシンプルなダイアログです。以下のように実装します。

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

namespace DialogApp.Dialogs
{
    [Serializable]
    public class CompletedDialog : IDialog<object>
    {
        public Person Person { get; set; }

        public CompletedDialog(Person person)
        {
            this.Person = person;
        }

        public async Task StartAsync(IDialogContext context)
        {
            await context.PostAsync($"こんにちは{this.Person.Age}歳の{this.Person.Name}さん");
            context.Done((object)null);
        }
    }
}

そして、ダイアログの連携部分を書いていきます。 MessagesControllerクラスにダイアログをChainするファクトリメソッドを作成します。以下のようなメソッドです。

private static IDialog<object> CreateDialog()
{
    return Chain.From(() => new InputPersonInfoDialog())
        .ContinueWith<Person, object>(async (ctx, r) =>
        {
            var p = await r;
            return new CompletedDialog(p);
        });

}

ContinueWithメソッドでダイアログを連結するようなイメージです。型引数は、前のダイアログが返す型と自分が返す型です。今回は最初のダイアログがPersonを返して、次のダイアログは何も返さないのでobjectとしています。ContinueWithIBotContextIAwaitable<第二型引数>なのでawaitして前のダイアログの結果を受け取ります。そして、それをもとに次のダイアログを作ります。

MessagesControllerPostメソッドを以下のように書き換えてChainメソッドでつないだダイアログを使うようにしましょう。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        await Conversation.SendAsync(activity, CreateDialog);
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

実行すると以下のようになります。繰り返し入力もいけるようになりました。

f:id:okazuki:20160927211654p:plain

フォーム入力に特化したダイアログ

さて、クラスに定義されたプロパティの値を埋めていくという処理は結構やることが多いと思います。FormDialogというクラスがこの処理をやってくれます。FormDialogIForm<T>から作ることができます。IForm<T>FormBuilder<T>というビルダークラスがいて、これを使って細かなカスタマイズが可能になっています。とりあえず一番簡単な使い方は以下のようになります。

private static IForm<Person> CreateForm()
{
    return new FormBuilder<Person>()
        .Build();
}

private static IDialog<object> CreateDialog()
{
    return Chain.From(() => FormDialog.FromForm(CreateForm))
        .ContinueWith<Person, object>(async (ctx, r) =>
        {
            var p = await r;
            return new CompletedDialog(p);
        });

}

InputPersonInfoDialogはいらなくなったので消してください。今までの挙動の違いとしては、最初に何かを話しかけるまで入力シーケンスが始まらないという点です。なので最初に、こんにちはとかhiとかと話しかけてあげましょう。実行すると以下のようになります。

f:id:okazuki:20160927212336p:plain

ちなみにenum型を持たせると、それを選択するようにできます。Personクラスを以下のように書き換えると(= 1重要)

using System;

namespace DialogApp.Models
{
    [Serializable]
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
        public Gender Gender { get; set; }
    }

    public enum Gender
    {
        Man = 1,
        Woman
    }
}

こんな選択肢が追加されます。

f:id:okazuki:20160927212806p:plain

フォームのカスタマイズ

色々カスタマイズが可能ですが、いくつかのカスタマイズ例を示したいと思います。

メッセージのカスタマイズ

プロパティ名がそのまま出るのって日本語では不自然ですよね。FormBuilderを使うと色々メッセージや値の範囲のチェックなどができるようになっています。細かく解説はしませんが、以下のような感じのコードが可能です。

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Description;
using Microsoft.Bot.Connector;
using Newtonsoft.Json;
using Microsoft.Bot.Builder.Dialogs;
using DialogApp.Dialogs;
using DialogApp.Models;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Bot.Builder.FormFlow.Advanced;

namespace DialogApp
{
    [BotAuthentication]
    public class MessagesController : ApiController
    {
        private static IForm<Person> CreateForm()
        {
            return new FormBuilder<Person>()
                .Message("あなたの情報を入力してください")
                .Field(nameof(Person.Name), "名前を入力してください", validate: (p, value) =>
                    // nullっていう名前の人はいないという無理な条件
                    Task.FromResult(((string)value) == "null" ?
                        new ValidateResult { Feedback = "nullという名前はダメです" } : 
                        new ValidateResult { IsValid = true, Value = value }))
                .Field(new FieldReflector<Person>(nameof(Person.Age))
                    .SetValidate((p, value) => Task.FromResult((long)value < 0 ? 
                        new ValidateResult { Feedback = "0歳以下はダメです" } :
                        new ValidateResult { IsValid = true, Value = value }))
                    .SetPrompt(new PromptAttribute("年齢を入力してください")))
                .Field(new FieldReflector<Person>(nameof(Person.Gender))
                    .SetPrompt(new PromptAttribute("性別を入力してください{||}"))
                    .SetDefine((p, field) =>
                    {
                        field.AddDescription(Gender.Man, "男性");
                        field.AddDescription(Gender.Woman, "女性");
                        return Task.FromResult(true);
                    }))
                .OnCompletion(async (context, p) =>
                {
                    await context.PostAsync($"こんにちは{p.Gender}の{p.Age}歳の{p.Name}さん");
                })
                .Confirm(p =>
                {
                    return Task.FromResult(new PromptAttribute($@"入力した情報は以下でいいですか?(はい/いいえ)

名前: {p.Name}

年齢: {p.Age}歳

性別: {(p.Gender == Gender.Man ? "男性" : "女性")}"));
                })
                .Build();
        }

        private static IDialog<object> CreateDialog()
        {
            return Chain.From(() => FormDialog.FromForm(CreateForm))
                .ContinueWith<Person, object>(async (ctx, r) =>
                {
                    var p = await r;
                    return new CompletedDialog(p);
                });

        }
        /// <summary>
        /// POST: api/Messages
        /// Receive a message from a user and reply to it
        /// </summary>
        public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            if (activity.Type == ActivityTypes.Message)
            {
                await Conversation.SendAsync(activity, CreateDialog);
            }
            else
            {
                HandleSystemMessage(activity);
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;
        }

        private Activity HandleSystemMessage(Activity message)
        {
            if (message.Type == ActivityTypes.DeleteUserData)
            {
                // Implement user deletion here
                // If we handle user deletion, return a real message
            }
            else if (message.Type == ActivityTypes.ConversationUpdate)
            {
                // Handle conversation state changes, like members being added and removed
                // Use Activity.MembersAdded and Activity.MembersRemoved and Activity.Action for info
                // Not available in all channels
            }
            else if (message.Type == ActivityTypes.ContactRelationUpdate)
            {
                // Handle add/remove from contact lists
                // Activity.From + Activity.Action represent what happened
            }
            else if (message.Type == ActivityTypes.Typing)
            {
                // Handle knowing tha the user is typing
            }
            else if (message.Type == ActivityTypes.Ping)
            {
            }

            return null;
        }
    }
}

プロパティのPromptAttribute{||}は選択肢を出すものです。PromptAttribute内で改行するには、改行2つで改行できます。

こんな感じになります。

f:id:okazuki:20160927220728p:plain

複雑なダイアログ

Chainには様々なメソッドがあります。

Dialogs | Bot Builder SDK C# Reference Library | Bot Framework

PostToChainで始まり、LINQを使って処理を組み立てることができます。Switchを使って以下のように分岐をすることもできます。

private static IDialog<object> CreateDialog()
{
    return Chain.PostToChain()
        .Select(x => x.Text)
        .Switch(
            new RegexCase<string>(new Regex("^助けて$"), (ctx, value) => "食べたいものを言ってね。"),
            new Case<string, string>(x => x.Contains("寿司"), (ctx, value) => $"かずあきさんの財布で{value}"),
            new DefaultCase<string, string>((ctx, x) => x))
        .PostToUser()
        .Loop();
}

f:id:okazuki:20160927222726p:plain

Switchでダイアログを返すこともできます。そのときはUnwrapとセットで使う感じです。

private static IDialog<object> CreateDialog()
{
    return Chain.PostToChain()
        .Select(x => x.Text)
        .Switch(
            Chain.Case(new Regex("^助けて$"), (ctx, x) => Chain.Return("食べたいものを言ってね")),
            Chain.Case((string x) => x == "寿司", (ctx, x) => Chain
                .Return($"かずあきさんの財布で{x}が食べたい?(はい/いいえ)")
                .PostToUser()
                .WaitToBot()
                .Select(y => y.Text)
                .Select(y => y == "はい" ? "かずあきさんの財布で寿司が食べたい!!" : "そうはいっても本音では食べたい!!")),
            Chain.Default<string, IDialog<string>>((ctx, x) => Chain.Return($"{x}が食べたい")))
        .Unwrap()
        .PostToUser()
        .Loop();
}

f:id:okazuki:20160927224124p:plain

Botのデプロイ

普通にAzureのApp Serviceにデプロイできます。 仮に、https://okazukibottest.azurewebsites.net/にデプロイしたものとして、公開手順を説明します。

Bot FrameworkのサイトでRegister Botを選択します。

f:id:okazuki:20160927224558p:plain

必要事項を入れていきます。Nameは適当に、bothandleはボットのハンドルネームです。

Messaging endpointは、URL/api/messagesです。今回の場合はhttps://okazukibottest.azurewebsites.net/api/messagesになります。

Configurationの項目の下にある「Create Microsoft App ID and password」をクリックして、アプリを登録します。 「アプリシークレットを作成して続行」をクリックしてアプリのシークレットを控えておきます。アプリIDが入力されます。あとで使うので控えておきます。

Publisher Profileを適当に入力したら完成です。同意してRegisterしましょう。

控えてアプリIDとシークレットをWeb.configのappSettingsのMicrosoftAppIdとMicrosoftAppPasswordに設定して再デプロイします。

Botの画面でTestを押して成功すれば準備完了です。

f:id:okazuki:20160927225343p:plain

Add To Skypeを押すと自分のスカイプに登録して試すことができます。そのほかにも、このページからSlackなど様々なチャットサービスに登録ができます。

f:id:okazuki:20160927225638p:plain

それでは良いBot生活を!

この先

LUISとの連携機能もあったりするので、それを使えば、より自然な文章を解析することができるようになります。

www.luis.ai

Language | Documentation | Bot Framework