2016/12/27

MS BotFrameworkをLine対応する

こんにちは、akkyです。

この記事はチャットボット Advent Calendar 2016の20日目の記事です。
http://qiita.com/advent-calendar/2016/chatbot

今回は、MS BotFrameWorkをLine対応する方法について、記述したいと思います。
MS BotFrameWorkは、Botを簡単に作成・公開するためのフレームワークです。
https://dev.botframework.com/

このフレームワークは、facebook MessangerやSlackなど、多様なチャットツールに対応しているのですが、Line対応は残念ながらされていません(そのうちされると思いますが)。

そこで、BotFrameWorkをWebサービス化することで、Line対応してみたいと思います。今回はBotのサンプルではHello World的扱いの、言われたことをおうむ返しする「おうむ返しBot」を作りたいと思います。


基盤としては、Azureを選択しました。Bot FrameWorkを動かす基盤としてはAzureを選択せざるを得ないので、Azureで統一しました。

また、今回のサンプルは、以下の点についても考慮しています。

実際のサービス化に向けて、上記のほかに考慮したほうが良い点や、技術検証の必要がある点などを、コメントして頂けると、筆者は泣いて喜びます(認証認可に関する課題は認識していますが、ほかにもあればぜひコメントをください)

では参りましょう。

0.事前準備

1.システムイメージ
システムイメージはこちらになります



2.MS BotFrameworkを使用しておうむ返しBOTを実装し、リリースする
2.1.おうむ返しBOTを実装する
  • Visual Studio Communityを開き、新規プロジェクトを作成します(ファイル→新規作成→プロジェクト)。この際、テンプレートは「JavaScript/Node.js/Blank Azure Node.js Web Application」を選択します。
  • npmを使用して依存するライブラリをインストールします。今回は、「restify」と「botbuilder」をインストールします。
  • 次に、BOTを実装します。下記のソースを、server.jsに貼り付けてください

var restify = require('restify');
var builder = require('botbuilder');

//=========================================================
// Bot Setup
//=========================================================

// Setup Restify Server
var server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, function () {
    console.log('%s listening to %s', server.name, server.url);
});

// Create chat bot
var connector = new builder.ChatConnector({
    appId: 'Your APPID',
    appPassword: 'Your APP Password'
});
var bot = new builder.UniversalBot(connector);
server.post('/api/messages', connector.listen());

//=========================================================
// Bots Dialogs
//=========================================================

bot.dialog('/', new builder.IntentDialog()
    .matches(/^hello/i, function (session) {
        session.send("Hi there!");
    })
    .onDefault(function (session) {
        session.send("You said: %s", session.message.text);
    }));

2.2.Azure App Serviceにリリースする
  • [ソリューションエクスプローラー]からプロジェクトを選択して右クリックし、[公開] を選択します。
  • 発行先を選択します。Microsoft Azure App Service(A)を選択してください。
  • AppService選択のウィンドウが開きますので、「新規発行」をクリックします

  • AppService作成のウィンドウで各種情報を入力し、AppServiceを作成します
    • API Apps の名前: デフォルトのままでもOKですが、後々管理しやすい名前を入力します。
    • サブスクリプション: デフォルトのままでOK。
    • リソースグループ: 判別しやすい名前を入力します。今回初めてAzureの環境を使用するのではなく、既存のものがあれば、既存で使用しているものを選択してもOK。
    • AppServiceプラン: その横の [新規作成] をクリックして、別ウインドウで設定を行います。プラン作成後、AppServiceを作成します。
  • AppServiceプランの構成ウィンドウで各種情報を入力し、AppServiceプランを作成します
    • App Serviceプラン: デフォルトのままでOK。
    • 場所:Japan East/West
    • サイズ:無料でOK。
  • 発行ウィンドウに遷移後発行ボタンを押下し、AppServiceにリリースします。
    • 各種情報はデフォルトのままでOKです。
    • 宛先URLは、Bot Directoryに対する登録時に使用するので、メモ帳等にコピーしておいてください。

2.3.Bot Directoryへ登録する
  • 作成したBotを Bot Directoryに登録します(https://dev.botframework.com/
    • Bot Profileを入力します
      • Name:AppService作成時に指定した名前を入力します。
      • Bot handle:Nameで指定したAppServiceの名前を入力します。
      • Description:本来はBOTの説明を入力する必要がありますが、今回はNameで入力したAppServiceの名前をそのまま入力しましょう。
    • Configurationを入力します
      • Messaging endpoint:AppService作成時に自動生成された宛先URLについて、httpをhttpsに、AppService名.azurewebsites.netの後に「/api/messages」をつけます
      • Manage Microsoft App ID and passwordボタンを押下し、App IDとパスワードを生成します。生成したApp IDとパスワードは、後でBOTの設定・実装に使うので、メモ帳などに残しておきます。
      • 登録条件を確認し、登録します。
  • 登録したBOTをWeb Service化します
    • My Botsから登録したBOTを選びます。
    • Add Another ChannelからDirect Lineを追加します
      • Addリンクを押下し、追加画面に遷移します
      • Add new siteリンクを押下し、Channel名入力画面を表示します。AppService作成時に指定した名前を入力し、Doneボタンを押下します
      • Configure Direct Line画面に遷移するので、Secret keysのshowリンクを押下してBOTへアクセスする際に使用するシークレットキーを表示し、メモ帳などにコピーして残します。
2.4.Bot Directoryで取得したアプリIDとパスワードをweb.configとserver.jsに反映し、再リリースする
  • web.configのappSettingsタグに以下の内容を追記してください。「Your BOT App ID」「Your BOT Password」は、BOT Directoryで取得した値を入力してください。
<add key="BOTFRAMEWORK_APPID" value="Your BOT App ID" />
<add key="BOTFRAMEWORK_APPSECRET" value="Your BOT Password" />
  • server.jsの変更箇所は、以下の通りです。「Your APPID」「Your APP Password」は、BOT Directoryで取得した値を入力してください
var connector = new builder.ChatConnector({
    appId: 'Your APPID',
    appPassword: 'Your APP Password'
});
  • 再発行時の手順は、上記設定変更後に以下の手順で行います
    • 上記セッティングを変更した後、[ソリューションエクスプローラー]からプロジェクトを選択して右クリックし、[公開] を選択します。
    • 各種情報の変更はせずに、公開ボタンを押下します。


3.Gateway/Dispatcherを実装する
3.1.Function Appを作成する
  • 新規→Market Placeから検索→「Function App」を指定して検索→検索結果から「Function App」を選択します
  • 作成ボタンを押下します
  • 各種情報を入力し、Function Appを作成します
    • アプリ名:管理しやすい任意の名前をつけます。
    • サブスクリプション:デフォルトのままでOK。
    • リソースグループ:「2.2.Azure App Serviceにリリースする」にて作成したリソースグループを指定。
    • ホスティングプラン:デフォルトのままでOK。
    • 場所:Japan East/Westを指定。

3.2.Gateway/Dispatcherを実装する
  • Channel Access TokenをLineDevelopers画面( Line Business Centerから入る )から取得します。
  • Azure PortalのTOPで「すべてのリソース」を選択し、3.1.で作成したFunction APPを選択します。
  • Gatewayを実装します。(デフォルト名称:HttpTriggerCSharp1)
    • 新しい関数ボタンを押下します。
    • テンプレート「HttpTrigger-CSharp」を選択します。
    • 開発メニューを選び、下記のソースを貼り付けて保存します

#r "Newtonsoft.Json"
using System.Net;
using System.Net.Http;
using Newtonsoft.Json;
using System.Threading.Tasks;

public static async Task<string> Run(HttpRequestMessage req, TraceWriter log)
{
    log.Info("C# HTTP trigger function processed a request.");
    // Get request body
    dynamic data = await req.Content.ReadAsStringAsync();
    return data;
}

    • 統合メニューを選び、入出力の内容を下記内容に修正します
      • トリガー(HTTP(req)):
        • 許可されている HTTP メソッド:All methods
        • モード:Standar
        • 要求パラメーター名:req
        • ルート テンプレート:未入力
        • 承認レベル:Function
      • 出力(Azure Queue Storage output ($return)):
        • メッセージパラメータ名:$return
        • キュー名:incoming-queue
        • ストレージアカウント接続:AzureWebJobsDashboard
  • Dispatcherを実装します。(デフォルト名称:QueueTriggerCSharp1)
    • 新しい関数ボタンを押下します。
    • テンプレート「QueueTrigger-CSharp」を選択します。
    • 開発メニューを選び、下記のソースを貼り付けて保存します。
    • BotのWebサービス化の際に取得した、BOTへアクセスする際に使用するシークレットキーをInnerClassForReplyLine.ReplyToLineのstring botAccessToken = "Your Bot SecretCd";の代入値を置き換えます。
    • Line Developersから取得した、Channel Access TokenをInnerClassForReplyLine.ReplyToLineのstring lineChannelAccessToken = "Your Line Channel Access Token";の代入値を置き換えます。

#r "Newtonsoft.Json"
#r "System.Runtime.Serialization"

using System;
using System.IO;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using Newtonsoft.Json;

public static void Run(string myQueueItem, TraceWriter log)
{
    log.Info($"C# Queue trigger function processed: {myQueueItem}");
    log.Info("キューから取り出した時刻:" + DateTime.Now.ToString());

    EventList events = JsonConvert.DeserializeObject<eventlist>(myQueueItem);

    foreach (Event eventItem in events.Events)
    {
        InnerClassForReplyLine.ReplyToLine(eventItem, log);
    }
}

public class InnerClassForReplyLine
{

    public static void ReplyToLine(Event item, TraceWriter log)
    {
        //ConversationIDの取得取得
        //本来は、ユーザー単位に30分間は同じIDを維持する必要があるが、今回は便宜上呼ばれるたびに取得する
        string urlForGetConvId = "https://directline.botframework.com/v3/directline/conversations";
        string botAccessToken = "Your Bot SecretCd";
        string authCd = "Bearer "+botAccessToken;
        var result = InnerClassForReplyLine.PostRequest(urlForGetConvId, authCd, null, log);
        log.Info("ConvId取得完了時刻:" + DateTime.Now.ToString());

        Conversation conversation = JsonConvert.DeserializeObject<conversation>(result.Result);

        //BotFWへのメッセージ送信
        string urlForFW = "https://directline.botframework.com/v3/directline/conversations/" 
+ conversation.ConversationId + "/activities";

        //送信用オブジェクトの生成
        SendMsgTextToFW msg = new SendMsgTextToFW();
        msg.Type = "message";
        msg.Text = item.Message.Text;
        msg.From = new SendToFwFromId();
        msg.From.Id = item.Source.UserId;

        //Serializerを使ってオブジェクトをMemoryStream に書き込み
        DataContractJsonSerializer jsonSer = new DataContractJsonSerializer(typeof(SendMsgTextToFW));
        MemoryStream ms = new MemoryStream();
        jsonSer.WriteObject(ms, msg);
        ms.Position = 0;

        // StreamReader で StringContent (Json) を生成
        StreamReader sr = new StreamReader(ms);
        StringContent content = new StringContent(sr.ReadToEnd(), System.Text.Encoding.UTF8, 
"application/json");

        //        log.Info("FWへの送信内容:" + content.ReadAsStringAsync().Result);
        log.Info("FWへの送信直前:" + DateTime.Now.ToString());
        //FWにリクエストを送信
        var resultForSendFW = InnerClassForReplyLine.PostRequest(urlForFW, authCd, content, log);
        log.Info("FWへの送信結果:" + resultForSendFW.Result);
        log.Info("FWへ送信完了時刻:" + DateTime.Now.ToString());

        //BotFWからレスポンスの取得
        //本来はWaterMarkの管理をしなければいけないが、今回は省略する
        var resultGetFromFW = InnerClassForReplyLine.GetPostResult(urlForFW, authCd, log);
        log.Info("FWからの結果取得内容:" + resultGetFromFW.Result);
        log.Info("FWから結果取得時刻:" + DateTime.Now.ToString());

        //FWからの取得結果を復元
        ResponseFromFW responseFromFW = JsonConvert.DeserializeObject<responsefromfw>(resultGetFromFW.Result);

        //Lineへのメッセージ送信
        //Lineへのメッセージ送信用オブジェクトの生成
        SendMsgHeaderToLine sendMsgHeaderToLine = new SendMsgHeaderToLine();
        sendMsgHeaderToLine.ReplyToken = item.ReplyToken;

        SendMsgBodyToLine sendMsgBodyToLine = new SendMsgBodyToLine();
        sendMsgHeaderToLine.Messages = new SendMsgBodyToLine[1];
        sendMsgBodyToLine.Type = "text";
        sendMsgBodyToLine.Text = responseFromFW.Activities[responseFromFW.Activities.Length - 1].Text;
        sendMsgHeaderToLine.Messages[0] = sendMsgBodyToLine;

        string postUrlForSendline = "https://api.line.me/v2/bot/message/reply";
        string lineChannelAccessToken = "Your Line Channel Access Token";
        string authCdForSendLine = "Bearer {" + lineChannelAccessToken + "}";

        //シリアライザーのインスタンス化
        DataContractJsonSerializer jsonSerForSendLine
 = new DataContractJsonSerializer(typeof(SendMsgHeaderToLine));
        MemoryStream ms2 = new MemoryStream();

        // Serializerを使ってオブジェクトをMemoryStream に書き込み
        jsonSerForSendLine.WriteObject(ms2, sendMsgHeaderToLine);
        ms2.Position = 0;

        // StreamReader で StringContent (Json) を生成
        StreamReader sr2 = new StreamReader(ms2);
        StringContent contentForSendLine = new StringContent(sr2.ReadToEnd(), System.Text.Encoding.UTF8, 
"application/json");

        log.Info("Line送信直前時刻:" + DateTime.Now.ToString());
        //        log.Info("ラインへの送信内容:" + contentForSendLine.ReadAsStringAsync().Result);
        var resultSendLine = PostRequest(postUrlForSendline, authCdForSendLine, contentForSendLine, log);
        log.Info("ラインへの送信結果:" + resultSendLine.Result);
        log.Info("Line送信完了時刻:" + DateTime.Now.ToString());
    }

    public async static Task<string> PostRequest(string url, string authCd, StringContent content, 
TraceWriter log)
    {
        var client = new HttpClient();
        client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authCd);
        client.DefaultRequestHeaders.Add("contentType", "application/json");

        var response = await client.PostAsync(url, content);
        if (response.IsSuccessStatusCode)
        {
            log.Info("Post成功");
        }
        else
        {
            // 応答ステータスコードを表示します。 
            String failureMsg = "HTTP Status:" + response.StatusCode.ToString() + " – Reason: " + 
response.ReasonPhrase;
            log.Info(failureMsg);
        }

        string result = await response.Content.ReadAsStringAsync();
        return result;
    }

    public async static Task<string> GetPostResult(string url, string authCd, TraceWriter log)
    {
        var client = new HttpClient();
        client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", authCd);
        client.DefaultRequestHeaders.Add("contentType", "application/json");

        var response = await client.GetAsync(url);
        if (response.IsSuccessStatusCode)
        {
            log.Info("Get成功");
        }
        else
        {
            // 応答ステータスコードを表示します。 
            String failureMsg = "HTTP Status:" + response.StatusCode.ToString() + " – Reason: " + 
response.ReasonPhrase;
            log.Info(failureMsg);
        }

        string result = await response.Content.ReadAsStringAsync();
        return result;
    }
}

public class EventList
{
    [JsonProperty(PropertyName = "events")]
    public Event[] Events { get; set; }
}

public class Event
{
    [JsonProperty(PropertyName = "type")]
    public string Type { get; set; }

    [JsonProperty(PropertyName = "replyToken")]
    public string ReplyToken { get; set; }

    [JsonProperty(PropertyName = "source")]
    public Source Source { get; set; }

    [JsonProperty(PropertyName = "timestamp")]
    public string Timestamp { get; set; }

    [JsonProperty(PropertyName = "message")]
    public Message Message { get; set; }
}

public class Source
{
    [JsonProperty(PropertyName = "userId")]
    public string UserId { get; set; }

    [JsonProperty(PropertyName = "type")]
    public string Type { get; set; }
}

public class Message
{
    [JsonProperty(PropertyName = "type")]
    public string Type { get; set; }

    [JsonProperty(PropertyName = "id")]
    public string Id { get; set; }

    [JsonProperty(PropertyName = "text")]
    public string Text { get; set; }
}

public class Conversation
{
    [JsonProperty(PropertyName = "conversationId")]
    public string ConversationId { get; set; }

    [JsonProperty(PropertyName = "token")]
    public string Token { get; set; }

    [JsonProperty(PropertyName = "expires_in")]
    public string Expires_in { get; set; }
}

// BotFWにテキストデータMSGを送信する際のデータ
[DataContract]
public class SendMsgTextToFW
{
    [DataMember(Name = "type")]
    public string Type { get; set; }
    [DataMember(Name = "from")]
    public SendToFwFromId From { get; set; }
    [DataMember(Name = "text")]
    public string Text { get; set; }
}

// BotFWにMSGを送信する際に指定する送信元
//(ロジック上は使用しないが、BotFWからの受け取りにも使用)
[DataContract]
public class SendToFwFromId
{
    [DataMember(Name = "id")]
    public string Id { get; set; }
}

//BotFWからのレスポンスヘッダ
[DataContract]
public class ResponseFromFW
{
    [DataMember(Name = "activities")]
    public Activity[] Activities { get; set; }

    [DataMember(Name = "watermark")]
    public string Watermark { get; set; }
}

//BotFWからのレスポンスボディ
public class Activity
{
    [DataMember(Name = "type")]
    public string Type { get; set; }

    [DataMember(Name = "channelId")]
    public string ChannelId { get; set; }

    [DataMember(Name = "conversation")]
    public ConversationWhenReplyFromFW Conversation { get; set; }

    [DataMember(Name = "id")]
    public string Id { get; set; }

    [DataMember(Name = "from")]
    public SendToFwFromId From { get; set; }

    [DataMember(Name = "text")]
    public string Text { get; set; }

}

//BotFWからのレスポンス時に使用するConversation
//ConversationID取得時と異なるIFで戻ってくるために定義する
public class ConversationWhenReplyFromFW
{
    [DataMember(Name = "id")]
    public string Id { get; set; }
}

//LineにMSGを送信する際に使用するヘッダ
[DataContract]
public class SendMsgHeaderToLine
{
    [DataMember(Name = "replyToken")]
    public string ReplyToken { get; set; }

    [DataMember(Name = "messages")]
    public SendMsgBodyToLine[] Messages
    {
        get;
        set;
    }
}

//LineにMSGを送信する際に使用するBody
[DataContract]
public class SendMsgBodyToLine
{
    [DataMember(Name = "type")]
    public string Type { get; set; }

    [DataMember(Name = "text")]
    public string Text { get; set; }
}


    • 統合メニューを選び、入出力の内容を下記内容に修正します
      • トリガー(Azure Queue Storage trigger (myQueueItem)):
        • メッセージパラメータ名:myQueueItem
        • キュー名:incoming-queue
        • ストレージアカウント接続:AzureWebJobsDashboard
4.LineAPIのWebHookの設定を修正する
  • 3.2.で実装した、GateWay(HttpTriggerCSharp1)のURLをメモ帳等にコピーします。
  • GateWayのURLを、LineDevelopers画面( Line Business Centerから入る )のWebHookURLに貼り付けます。
5.テストする
  • 作成したBotを、自分のLineアプリから追加します
    • Line@Manage(Line Business Centerから入る)から二次元バーコードを取得し、二次元バーコードを用いて追加します。
  • トークからBotに話しかけてみて、自分の発言と同じリプライが戻ってくれば、成功です。
6.最後に
  • 今回のプロトは、ConversationIDの管理(永続化、有効時間管理)、WaterMarkの管理(永続化)について省略していますので、次回はここも含んで対応したいと思います。
  • BotFrameworkについて、使ってみたレベルの記事が多く、本格利用はこれからだと思っています。
  • この記事をもとに、MS BotFrameworkを使ったLine対応BOTが一つでも生まれると、幸いに思います。

3 件のコメント:

  1. わかりやすく書かれています。ありがとうございます!

    ちょうどこの記事が公開された数時間前にApp ServiceのほうでDirect Clientの.NET SDKを使用して同じようなことを実装しようとしていましたが、この記事を紹介され、こちらの方法のほうがキューとFunctionを採用しているのでより良いと思いました。

    おかげさまで上記の手順に従い問題なくECHOができています。
    (ただし、BOT自体はNode.JSじゃなくてC#で実装しています。)

    2点ほどの問題点がありました。

    1. QueueTrigger-CSharp1の

    19行目の<EventList>
    39行目の<Conversation>
    77行目の<JsonConvert>

    の部分が、<>がHTMLタグとして処理されているせいで非表示されてしまうので、単なるソースのコピペだとスクリプトコンパイルできないです。

    2. 35行目の
    string authCd = "Your Bot SecretCd";
    の頭にBearerを入れる必要があります。そうしないとConversationIDの取得できず、POSTメッソドができないようなエラーが返されます。

    e.g. string authCd = "Bearer <Your Bot SecretCd";

    宜しくお願いします!

    返信削除
    返信
    1. このコメントは投稿者によって削除されました。

      削除
    2. Simon Nishiさま
      コメント、ありがとうございます

      早速対応いたしました

      今後とも宜しくお願い致します

      削除