[改訂]GitHubのイベントをSkypeのBotに喋らせる

**4版からの変更点**：**https://apis.skype.com からhttps://skype.botframework.com を使うようにしました。**こうすることで、Skype以外のBotへの応用がかんたんになります（例えばFacebookに通知するなど）。また、mark downやタグを使ってよりリッチな見た目のメッセージを作成できるようになります。
**その過程で、msg.jsonの形式が少し変わりました。**注意してください。

# 概要
## やりたいこと
GitHubへのプッシュ等のイベントをWebhookで送って、SkypeのBotにグループチャットで喋らせたい。

## いろいろな方法一覧
- Hubot ←よく知らない。
- Sevabot ←僕の環境だとインストールでこけた。
- Microsoft Bot Framework + Azure Functions  **←今回採用する方法**。

## Microsoft Bot Framework、Azure Functionsとは
- Microsoft Bot Frameworkは、Web上で動作するBotを作成できるサービス。
- Azure Functionsは、HTTPリクエストを受信したときに実行されるアプリを作れるサービス。

### いいところ
- すべてWeb上で動くので、どんな環境でも同じように使える。
- 何もインストールしないので、インストールでこけることがない。
- Skypeを常に起動しておく必要がない。
- コマンドラインから喋らすことさえ可能。
- GitHub以外のWebhookやWebhook以外のHTTPリクエストも受信して喋らせることができる。

### 悪いところ
- Azureがよくわからんし、わかりづらい。

## 流れ
- Step 1: Botの作成
- Step 2: コマンドラインからBotに喋らせる（動作確認）。
- Step 3: Function Appを作る。これでWebhookを受信する。
- Step 4: Function AppからBotに喋らせる。

## 必要なもの
- Microsoftのアカウント
- クレジットカード ←**今回の内容ではお金はかからない**が、支払い方法は用意しないといけません。

# Step 1: Botの作成
https://dev.botframework.com/ でBotを作ります。入力内容はこんな感じ：
![bot_creation.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/a4f776be-5b3a-0485-cb16-5999ecd4c063.gif)

- Bot handleの意味はよくわかりませんが、少なくともBotのページのURLで使われます。別になんでもいいですが、今後変更はできません。
- Messaging endpointは、**Botに対してSkype上での発言を送る場合は必要**ですが、今回は**Webhookを受信し、グループチャットで発言するだけなので不要**です。
- App ID、Passwordは大事です。今後使うので、ひとまず安全な場所に保管しましょう。

完了したら、グループチャットに追加できるよう設定しておきます：
![bot_configure_skype.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/392cc550-ce06-506b-2d76-8845a2b5bb0c.gif)

![bot_group_enable.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/ec608e7f-dcb7-0981-8bcf-5a31904a0e4b.gif)

**その後**、Add to Skypeをクリックして、Skypeのコンタクトに追加しておきます。
![bot_add_to_skype.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/736c0413-53fb-c33e-5a6d-0fddf42126bf.gif)



# Step 2: コマンドラインからBotに喋らせる（動作確認）
このステップを飛ばしてもいいですが、Web上でデバッグするのは面倒なので、やっておくのをおすすめします。

## 準備

### OAuth2 Tokenの取得
Botに発言させるためには、OAuth2 Tokenが必要です。
https\://login.microsoftonline.com/common/oauth2/v2.0/token にPOSTを発行すれば取得できます。Power Shellのコマンドだと、

```
Invoke-RestMethod -Uri https://login.microsoftonline.com/common/oauth2/v2.0/token -Method Post -Body "client_id=＜BotのApp ID＞&client_secret=＜Botのパスワード＞&grant_type=client_credentials&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default"
```

です。Linuxなどの方は適宜読み替えてください。
ここで、＜BotのApp ID＞や＜Botのパスワード＞は、Step 1で保存したものです。
結果はJsonで帰ってきます。なので、変数に代入してやれば中身を取り出せます。**このTokenの有効期限は1時間です**。

```
$data = Invoke-RestMethod -Uri .......
Set-Content key.txt $data.access_token   #Tokenをkey.txtというファイルに保存
```

### Conversation ID
Skypeのチャットルーム？にはIDが振られており、それをConversation IDと呼びます。Botを喋らせたい**グループチャットにBotを追加し**、`/get name`と打ち込みます。すると、name=**12:34567890abcdef1234567890abcdef12＠thread.skype**のようなConversation IDが帰ってきます。太字の部分全部、すなわち、**@thread.skypeまで含んだ部分**がConversation IDです。

### 発言内容のJsonを作っておく

```
{
    type: "message",
    text: "こんにちは :D"
}
```

`type`には`"message"`を指定します。`text`には好きなメッセージを入力しましょう。**日本語を含める場合は、UTF-8で保存してください**。
ファイルの名前はなんでもいいですが、msg.jsonとでもしておきましょう。

## 喋らせてみる
https://skype.botframework.com/v3/conversations/＜Conversation ID＞/activities にPOSTを発行すればBotが喋ります。
Power Shellだとこんな感じ：

```
$headers = @{
    "Authorization" = "Bearer ＜先程取得したOAuth2 Token＞"
}
Invoke-RestMethod -Uri https://skype.botframework.com/v3/conversations/＜Conversation ID＞/activities -Method Post -InFile msg.json -ContentType 'application/json' -Headers $headers
```

Bearerの後に1つスペースを空けるのを忘れずに。

![cltest.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/67769b6b-d8eb-53b2-52a6-acaa7851a52c.gif)

喋りました。
mark downやかんたんなタグを使うこともできます。また、ファイルを送るなども可能。詳しくは
https://blogs.msdn.microsoft.com/tsmatsuz/2016/08/31/microsoft-bot-framework-messages-howto-image-html-card-button-etc/ を参照。

# Step 3: Function Appを作る
- Azure Portal https://portal.azure.com/ にアクセスします。
- 初めてAzureを使う人は、サブスクリプション（課金方法）を作成する必要があります。従量課金を選んでおけば問題ないでしょう。
![subscription.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/bbe3cb06-b74d-e70e-9463-d5bda600d8a3.gif)
- Azure Portalから、Function Appを作成します。
![create_function.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/7f0be4ee-06d4-1081-1e1a-edb9235ebafb.gif)
- App Service プランは**F1 Free**を選びましょう。**その他は月額料金がかかります**。価格レベルを選択のところで**すべて表示**みたいなボタンを押さないとフリープランは表示されません。
![create_function2.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/3c249d35-b539-08bf-9fee-b33afc1cbb7a.gif)
- その他は適当に。
- 作成ボタンを押してしばらく待つと、Function Appが作成されます。それを開いて、新しい関数→GitHubWebHook-CSharpを選びます。関数名は適当に。
- 編集画面が出てきます。上部に表示されている実行ボタンをクリックした後、**関数のURL**と**GitHubシークレット**を、GitHubのWebhook設定画面の**Payload URL**、**Secret**に入力します。GitHubのWebhook設定画面のRecent DeliveriesのところにResponse **200**と出れば成功です。

# Step 4: Function AppからBotに喋らせる
ここまで来ればできたも同然です。ほしいイベントだけを拾って、先程Power ShellでやったことをC#でやるだけです。
イベントの種類は、ヘッダの**"X-GitHub-Event"**で区別できます。Jsonでデータが送られてくるので、イベントに応じて情報を取捨選択し、整形してBotに発言させます。
飛んでくる情報については、https://developer.github.com/v3/activity/events/types/ にまとめられています。でも、実際にプッシュなどをしてRecent Deliveriesから確認するのが確実かと思われます。

参考までに、僕の環境を載せておきます。GitHubからのイベントと、Travis CIからのイベント（GitHubの設定で、Let me select individual events→Statusにチェックを入れておけば、GitHub経由で飛んできます）を分けて通知するために、2つのBotを用意しています。
C#は全く触ったことがなかったので、C#を使ってる人からすれば良くないプログラムかもしれませんが。。。

```c#
#r "Newtonsoft.Json"
using System.Net;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

private static readonly SkypeBot GITHUB = new SkypeBot("＜App ID＞", "＜Password＞");
private static readonly SkypeBot TRAVIS = new SkypeBot("＜App ID＞", "＜Password＞");
private const string CONVERSATION_ID = "＜Conversation ID＞";

public static async Task<HttpResponseMessage> Run(HttpRequestMessage req, TraceWriter log)
{
    if(!req.Headers.Contains("X-GitHub-Event")){
        return req.CreateResponse(HttpStatusCode.OK);
    }
    string eventTag = req.Headers.GetValues("X-GitHub-Event").FirstOrDefault();
    dynamic data = await req.Content.ReadAsAsync<object>();
    
    createAndSendMessage(eventTag, data);
    
    return req.CreateResponse(HttpStatusCode.OK, "From Github");
}

private static void createAndSendMessage(string eventTag, dynamic data){
    if(eventTag == "pull_request"){
        string fromBranch = data.pull_request.head.@ref;
        string toBranch = data.pull_request.@base.@ref;

        if(data.action == "opened"){
            GITHUB.sendMessage(
                $"'{data.pull_request.user.login}' がPull Request '{fromBranch} -> {toBranch}' を作成しました。 {data.pull_request.html_url}",
                CONVERSATION_ID
            );
        }else if(data.action == "closed"){
            if(data.pull_request.merged == "true"){
                GITHUB.sendMessage(
                    $"'{data.pull_request.user.login}' によるPull Request '{fromBranch} -> {toBranch}' は承認されました :D",
                    CONVERSATION_ID
                ); 
            }else{
                GITHUB.sendMessage(
                    $"'{data.pull_request.user.login}' によるPull Request '{fromBranch} -> {toBranch}' は却下されました。 {data.pull_request.html_url}",
                    CONVERSATION_ID
                );
            }
        }
    }else if(eventTag == "push"){
        string raw = data.@ref;
        string branch = raw.Replace("refs/heads/", "");

        GITHUB.sendMessage(
            $"'{data.pusher.name}' がbranch '{branch}' をPushしました。",
            CONVERSATION_ID
        );
    }else if(eventTag == "create"){
        GITHUB.sendMessage(
            $"'{data.sender.login}' が{data.ref_type} '{data.@ref}' を作成しました。",
            CONVERSATION_ID
        );
    }else if(eventTag == "delete"){
        GITHUB.sendMessage(
            $"'{data.sender.login}' が{data.ref_type} '{data.@ref}' を削除しました。",
            CONVERSATION_ID
        );
    }else if(eventTag == "status"){
        string state = data.state;
        string branch = data.branches[0].name;

        if(state == "pending"){
            TRAVIS.sendMessage(
                $"branch '{branch}' の自動テストが進行中...",
                CONVERSATION_ID
            );
        }else if(state == "success"){
            TRAVIS.sendMessage(
                $"branch '{branch}' の自動テストが［成功］しました :D",
                CONVERSATION_ID
            );
        }else if(state == "failure" || state == "error"){
            TRAVIS.sendMessage(
                $"branch '{branch}' の自動テストが［失敗］しました。詳細は以下で閲覧できます： {data.target_url}",
                CONVERSATION_ID
            );
        }
    }
}

private class SkypeBot{
    private string appId;
    private string appSecret;

    public SkypeBot(string appId, string appSecret){
        this.appId = appId;
        this.appSecret = appSecret;
    }

    public void sendMessage(string message, string conversationId){
        string bearer = getBearer(appId, appSecret);
        string conversationUri = $"https://skype.botframework.com/v3/conversations/{conversationId}/activities";

        dynamic jsonMessage = new JObject();
        jsonMessage.type = "message";
        jsonMessage.text = message;
        byte[] messageBytes = System.Text.Encoding.UTF8.GetBytes(jsonMessage.ToString());

        var req = WebRequest.Create(conversationUri);
        req.Method = "POST";
        req.ContentType = "application/json";
        req.Headers.Add("Authorization", $"Bearer {bearer}");

        var requestStream = req.GetRequestStream();
        requestStream.Write(messageBytes, 0, messageBytes.Length);
        requestStream.Close();

        var response = req.GetResponse();
    }

    private string getBearer(string appId, string appSecret){
        string body = $"grant_type=client_credentials&client_id={appId}&client_secret={appSecret}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default";
        byte[] bodyBytes = System.Text.Encoding.ASCII.GetBytes(body);

        var req = WebRequest.Create("https://login.microsoftonline.com/common/oauth2/v2.0/token");
        req.Method = "POST";
            
        var requestStream = req.GetRequestStream();
        requestStream.Write(bodyBytes, 0, bodyBytes.Length);
        requestStream.Close();

        var response = req.GetResponse();
        var responseStream = response.GetResponseStream();
        var streamReader = new StreamReader(responseStream);
        string responseBody = streamReader.ReadToEnd();
        streamReader.Close();
        responseStream.Close();

        dynamic responseBodyJson = JsonConvert.DeserializeObject(responseBody);
        return responseBodyJson.access_token;
    }
}
```

結果はこんな感じです。

![result.gif](https://qiita-image-store.s3.amazonaws.com/0/111575/45efb0cc-5330-ab97-3ca8-a9b39dac401d.gif)

なぜか、進行中のWebhookが2回飛んできますが、気にしないことにします。

# おわりに
## 応用
- GitHub以外のWebHookで使いたいときは、関数を作成するときにGenericWebHook-CSharpを選べばいいです。
- HttpTriger-CSharpを選べば、一般のHttpリクエストに対し実行できます。
- コマンドラインで動作確認したときの方法を使えば、スケジューラを使って定時に喋らせたりもできます。
- プログラムから喋らすことも。
- 今回はC#を使いましたが、JavaScriptやF#を使うこともできます。

## 感想
Botの情報には、「話しかけると返事をする」といった類のものが多いです。そちらに迷い込んでしまったせいで、たったこれだけのことに何日も費やしてしまいました。
この情報が皆さんの役に立つことを願います。・・・というか、需要あるのか？これ。