Slack App+GASでボタン選択式botを作ろう

# はじめに
大学の研究室で交代制のゴミ出し当番が決まっているのですが、出し方のルールがよくわからないことを理由にゴミ出しをサボる人がいてゴミが溢れかえってます。そこで「**ゴミの捨て方を優しく教えてくれちゃうbot**を研究室Slackに導入して~~**言い訳を封じよう**！！~~みんなの役に立とう！！」と思い、ウルトラ優秀サイトであるQiitaとかを参考にちょろちょろ〜っと作ってみるか！と意気込んだものの、意外と苦戦したので, ゼロ知識から**Slack App** + **Google Apps Script (GAS)**でbotを作るまでに詰まったポイントと解決方法, 参考になった記事をまとめます。

SlackAppの開発は初めてで、GASどころかJavascriptも書いたことなく、2週間程度で作ったので何か間違っている点などありましたら是非ご指摘お願いします！

# 本記事の目的

- 裏側の処理を**Google Apps Script**で行う
- Slackチャンネル内の**スラッシュコマンドに反応**して応答
- **選択肢ボタン**をユーザに提示し, 押されたボタンに応じて応答を変える
- チャンネルがbotメッセージに埋もれないようにbotを**呼び出した人にのみメッセージを表示**
- **Google Driveに置いた画像を添付**する

を実現するbotの作成を目的とします。だいたいこんな感じの構造です。
![qiita_1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/cd562558-76d8-186b-19a6-7c5484ae26b0.png)

# Slack Appの設定(前半)
まずはbotを導入をしたいSlackにAppを作成します。基本的な設定方法は省略します。


### パーミッションの設定
Slack Appに持たせる権限を設定します。Appのメニューの **Features** > **OAuth&Permissions**を選択、**Select Permission Scopes**で下記の4種を追加しておきましょう。

<img width="671" alt="スクリーンショット 2019-06-30 20.45.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/8aa63af6-1fdc-0dc9-c258-8304f768325a.png">

<dl>
   <dt>Send messages as (App名):</dt>
   <dd>botとしてメッセージを送信するために必要なパーミッションっぽいです。</dd>
   <dt>Send messages as user:</dt>
   <dd>ユーザーとしてメッセージを送信するために必要なパーミッションっぽいです。今回はbotとしてメッセージを送信するだけなので必要ないかもしれないです。(未検証)</dd>
   <dt>Post to specific channels in Slack:</dt>
   <dd>Incoming Webhookを使わないので必要ないかもしれないです。（未検証）</dd>
   <dt>Add slash commands and add actions to messages (and view related content):</dt>
   <dd>スラッシュコマンドを利用するために必要な権限です。</dd>
</dl>

チャンネルへのインストールをした後でこの設定を変更すると、権限が変わるので再インストールを求められます。指示通りに再インストールをしましょう。

### Verification Tokenの取得
後に触れるGASはWebアプリケーション用URLを発行すると、URLを知っている人であればだれでも実行することができます。可能性は低いですが、このURLを第三者に知られるといたずら(ひたすらbotを勝手に動かされるなど)をすることが可能なので、**特定のSlack Appからのみアクセスできるように照合用のトークンを取得**しましょう。

作成したSlack AppのapiでサイドメニューからSettings > **Basic Information**を選択し App Credentials の**Verification Token**を確認しておきます。

<img width="710" alt="スクリーンショット 2019-06-27 18.02.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/d9b5f471-b6eb-7345-3cd3-9125aa9d1cbe.png">

とりあえずSlack App側はこれで放置します。

# Google Driveの設定
今回は応答メッセージに画像を添付しますが、画像データをGoogleドライブに置き、Slackアプリからアクセスして利用する方法を採用します。画像をWeb上に置く方法はいろいろありますがGoogle使うのがなんとなく安心感あるので()

### 画像公開用URLの取得
Googleドライブの任意の場所に画像をアップロードし、右クリックで**共有可能なリンクの取得**を選択、画像のステータスを公開に切り替えてURLを取得します(勝手にクリップボードにコピーされます)。 Google Driveですが、別に**GASと同じアカウントである必要はありません**。
![スクリーンショット 2019-06-28 3.20.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/30172ef3-1689-ba4b-f1ba-101b21fa4883.png)
URLは``https://drive.google.com/open?id=hogehogehogehogehoge``
みたいな形になると思います。これによりドライブの持ち主以外もこの画像を閲覧できるようになるのですが、Slack AppではこのURLから画像を挿入することができません。

### 参照用URLの作成
そこでこちらの記事を参考にSlack Appが参照できるURLを作ります。

- [Google Drive上のファイルに直接アクセスする](https://qiita.com/rot-z/items/299ac40361690c51ce1d)

`https://drive.google.com/uc?id=hogehogehogehogehoge`

このように取得したURLの`id=`以降のほげほげ部分を同様に挿入すれば参照用アドレスの出来上がりです。

# Google Apps Scriptの設定
Slack側ではプログラムを組んで処理をすることができないので、なんらかの外部サービスで処理を行う必要があります。Webアプリとして使えるプラットフォームにGoogle Cloud Function(**有料**)やAWS lambda(だいたい**有料**)やがありますが、今回は処理が軽いので**無料**で使える**Google Apps Script (GAS)**を使って裏処理を行います。無料サイコー!!フゥー(👆 ՞ਊ ՞)👆

## Goodle Apps Scriptの作成とWebアプリケーション化方法の確認
基本のGASの作成方法とWebアプリ化の方法は省略します。GASを使ったことのない人でもこちらのサイトを参考に導入から順に学べます。

- [Google Apps Scriptで簡単にWebアプリケーションを公開する方法](https://www.indetail.co.jp/blog/181219/)

### Slack用ライブラリの導入
GASでSlack Appの処理をする場合はライブラリが必要となります。こちらの記事の著者様が作ったライブラリを導入しましょう。

   - [Slack BotをGASでいい感じで書くためのライブラリを作った](https://qiita.com/soundTricker/items/43267609a870fc9c7453)

メニューの **リソース** > **ライブラリ** を選択し、ライブラリキー`M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO`でライブラリを追加します。

## bot用Webアプリの作成
では具体的にWebアプリケーションを作ってみましょう。だいたいの構造はこちらの記事を参考にしました。

  - [Slack BOTでMessage Buttonsを使う](http://www.pre-practice.net/2017/11/slack-botmessage-buttons.html?m=1)

以下のアプリ①とアプリ②はそれぞれ**別のプロジェクト**としてして作成します。

### スラッシュコマンドを受け取るアプリ : アプリ①
#### 全体コード

Slack Appがスラッシュコマンド`\gomibot`を受け付けたことによって、**リクエストURL**(= **アプリ①の公開URL**)を叩きます。

<details><summary><b>アプリ①のコード(クリックして展開)</b></summary><div>

```javascript:slash_receive.gs
function doPost(e) {
  var slack_token = 'トークン'; // Verification Tokenで取得したトークン
  // 指定したチャンネルからの命令しか受け付けない
  if (slack_token != e.parameter.token) {
    throw new Error(e.parameter.token);
  }

  // 返答データ本体
  var data = {
    "text": "Hello! I'm Gomi-bot. I'll tell you how to take out trash.", //アタッチメントではない通常メッセージ
    "response_type":"ephemeral", // ここを"ephemeral"から"in_chanel"に変えると他の人にも表示されるらしい（？）
    //アタッチメント部分
    "attachments": [{
      "title": "Language Select",//　アタッチメントのタイトル
      "text": "Please select language.",//アタッチメント内テキスト
      "fallback": "Yeeeeeeeeeeah!!!",//ボタン表示に対応してない環境での表示メッセージ. 
      "callback_id": "callback_button",
      "color": "#00bfff", //左の棒の色を指定する
      "attachment_type": "default",
      // ボタン部分
      "actions": [
        //ボタン1
        {
          "name": "eng",
          "text": "English",
          "type": "button",//
          "value": "language"
        },
        //ボタン2
        {
          "name": "jpn",
          "text": "日本語",
          "type": "button",
          "value": "language"
        }
        ]
      }]
  };
  // 　botを呼び出した人にのみ表示する
  //   返信データをJSON形式に変換してチャンネルに返す
  return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON);
}

```

</div></details>

#### Slack Appからのメッセージの受信

```javascript

function doPost(e) {
//中略
}
```

`function doPost(e)`はスラックからのメッセージを`e`で受け取り、実行される関数です。

#### トークンの認証

```javascript
var slack_token = 'トークン'; // Verification Tokenで取得したトークン
// 指定したチャンネルからの命令しか受け付けない
if (slack_token != e.parameter.token) {
  throw new Error(e.parameter.token);
}
```

叩かれたアプリ①は先ほど確認した[**Verification Token**](#verification-tokenの取得)(`'トークン'`部分にはこのトークンが入ります)とリクエスト元のトークン`e.parameter.token`が一致していることを確認し、一致しない場合はエラー終了します。

#### 送信データの設定

次に選択肢付きのメッセージデータを`var data`で定義します。



```javascript

var data = {
    "text": "Hello! I'm Gomi-bot. I'll tell you how to take out trash.", //アタッチメントではない通常メッセージ
    "response_type":"ephemeral", // ここを"ephemeral"から"in_chanel"に変えると他の人にも表示されるらしい（？）
    //アタッチメント部分
    "attachments": [{
      "title": "Language Select",//　アタッチメントのタイトル
      "text": "Please select language.",//アタッチメント内テキスト
      "fallback": "Yeeeeeeeeeeah!!!",//ボタン表示に対応してない環境での表示メッセージ. 
      "callback_id": "callback_button",
      "color": "#00bfff", //左の棒の色を指定する
      "attachment_type": "default",
      // ボタン部分
      "actions": [
        //ボタン1
        {
          "name": "eng",
          "text": "English",
          "type": "button",
          "value": "language"
        },
        //ボタン2
        {
          "name": "jpn",
          "text": "日本語",
          "type": "button",
          "value": "language"
        }
        ]
      }]
  };
```

<dl>
  <dt>"text"</dt>
  <dd>通常のメッセージ部分です。</dd>
  <dt>"response_type"</dt>
  <dd>投稿を他人にも見せるか否かを決めるオプションです。デフォルトで他人には見えない<code>"ephemeral"</code>となっているので書かなくても良いです。<code>"in_chanel"</code>に変えると他人にも表示されるらしい(?)です。</dd>
  <dt>"attachment"</dt>
  <dd>通常メッセージよりも装飾をつけられて豪華な感じ(語彙力)のアタッチメントというメッセージを定義できます。１つのメッセージに複数のアタッチメントを添付することも可能です。詳しいことは<a href='https://qiita.com/daikiojm/items/759ea40c00f9b539a4c8'>Slack API attachmentsチートシート</a>が参考になります。
     <dl>
        <dt>"title"</dt>
        <dd>アタッチメントのタイトル部分です。太いです。</dd>
        <dt>"text"</dt>
        <dd>アタッチメント内メッセージ部分です。</dd>
        <dt>"fallback"</dt>
        <dd>アタッチメント表示に対応してない環境で表示される代替テキストらしいです。スマホのプッシュ通知とかでしょうか。</dd>
        <dt>"color"</dt>
        <dd>アタッチメントの横に付く棒の色をカラーコードで指定できます。</dd>
        <dt>"actions"</dt>
        <dd>とりあえず今回は<b>ボタンを定義する部分</b>と考えます。<code>"actions"</code>内の<code>{}</code>部分を増やすことでボタンを増やすことができますが、私が試してみたところボタンメッセージとして送信できるのは<b>最大5個まで</b>です。(エラーにはなりませんが6個目以降は表示されません。)
            <dl>
               <dt>"name"</dt>
               <dd>なんかよくわからないですが識別用の名前です。この部分はメッセージでは見えません。</dd>
               <dt>"text"</dt>
               <dd>この部分が実際にボタンに表示される文章です</dd>
               <dt>"type"</dt>
               <dd>今回はボタンタイプなので<code>"button"</code>とします。</dd>
               <dt>"value"</dt>
               <dd>選択肢の識別用の名前です。<b>応答を分岐させるとき用の任意の識別名</b>です。ここもメッセージでは見えません。</dd>
            </dl>
</dd>
     </dl>
</dd>
</dl>

この内容だと下図のような送信メッセージになります。
<img width="476" alt="スクリーンショット 2019-06-27 15.14.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/a4c07841-f5e1-fb92-7acc-0057d5dde9fc.png">

#### チャンネルへのメッセージ送信
作った送信データをJSON形式に変換してチャンネルへ送り返します。

```javascript
return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON);
```
以上がスラッシュコマンドを受け取った際に実行されるアプリ①の内容です。次は選択肢ボタンを受け取るアプリ②について説明します。

------------------
### 選択肢を受け取るアプリ : アプリ②

#### 全体コード
ユーザーがボタン付きメッセージを受信し、ボタンをタップまたはクリックすると、この**アプリ②の公開URL**が叩かれます。~~英語がなんだか変です。~~

<details><summary><b>アプリ②のコード(クリックして展開)</b></summary><div>

```javascript:button_receive.gs
function doPost(e) {
  // ペイロード部分の取り出し
  var payload = JSON.parse(e["parameter"]["payload"]);
  var name = payload["actions"][0]["name"];
  var value = payload["actions"][0]["value"];
  
  // nameの値についてswitch分岐(nameを言語モードの分岐条件にしている)
  switch (name) {
    // 英語モードの場合
    case 'eng':
      var head_text = "English Mode";
      var quest_attachment = {
        "title": "Question Select",
        "text": "What do you want to know about?",
        "fallback": "Opps",
        "callback_id": "callback_button",
        "color": "#00bfff",
        "attachment_type": "default",
        "actions": [
          {
            "name": "eng",
            "text": "Trash list of each weekdays",
            "type": "button",
            "value": "week"
          },
          {
            "name": "eng",
            "text": "How to take out trash",
            "type": "button",
            "value": "howgomi"
          },
          {
            "name": "eng",
            "text": "Where are new plastic bags?",
            "type": "button",
            "value": "gomibag"
          },
          {
            "name": "eng",
            "text": "Which key corresponds to each room?",
            "type": "button",
            "value": "roomkey"
          },
          {
            "name": "eng",
            "text": "Exit",
            "type": "button",
            "value": "quit"
          }
        ]
      };
      break;
    // 日本語モードの場合
    case 'jpn':
      var head_text = '日本語モード';
      // 2段階目の選択肢ボタン用アタッチメント
      var quest_attachment = {
        "title": '質問選択',
        "text": '何について調べますか?',
        "fallback": "ほえ〜",
        "callback_id": "callback_button",
        "color": "#00bfff",
        "attachment_type": "default",
        "actions": [
          {
            "name": "jpn",
            "text": "各曜日のごみは？",
            "type": "button",
            "value": "week"
          },
          {
            "name": "jpn",
            "text": "ごみの捨て方",
            "type": "button",
            "value": "howgomi"
          },
          {
            "name": "jpn",
            "text": "新しいゴミ袋はどこ？",
            "type": "button",
            "value": "gomibag"
          },
          {
            "name": "jpn",
            "text": "部屋の鍵がわからない",
            "type": "button",
            "value": "roomkey"
          },
          {
            "name": "jpn",
            "text": "終了",
            "type": "button",
            "value": "quit"
          }
        ]
      }
      break;
  }

  // 選択肢に応じた応答をするためにvalueでswitch分岐する
  switch (value) {
    case 'week':
      var image = '画像URL'; // 添付画像のURL
      // 言語モードに応じた答えに分岐する
      switch (name) {
        case 'eng':
          var r_text = "Trash list of each weekdays";
          var exp_text = "Monday&Thursday's garbage is \"burning garbage\"\n Wednesday's garbage is \"non-burnable garbage\", \"pet bottle\" and \"bottle and can\" Please take out the garbage between 8:30 am to 9:30 am";
          break;
        case 'jpn':
          var r_text = "各曜日のごみ";
          var exp_text = "月曜日・木曜日のごみは「もえるごみ」です. \n 水曜日のごみは「もえないごみ」「ペットボトル」「ビン・カン」です.　\n AM8:30~9:30の間にゴミを出してください.";
          break;
      }
      break;
    case 'howgomi':
      var image = '画像URL';
      switch (name) {
        case 'eng':
          var r_text = "How to take out trash";
          var exp_text = "Make sure that \"XXX laboratory\" is written on the trash bag, and set a new trash bag in the trash box.";
          break;
        case 'jpn':
          var r_text = "ごみの捨てかた";
          var exp_text = "ゴミ袋に「〇〇研究室」が記入されているか確認し, 新しいゴミ袋をゴミ箱にセットします.";
          break;
      }
      break;
    case 'gomibag':
      var image = '画像URL';
      switch (name) {
        case 'eng':
          var r_text = "Where are new plastic bags?";
          var exp_text = "In the closet under the sink in the staff room";
          break;
        case 'jpn':
          var r_text = "新しいゴミ袋はどこ？";
          var exp_text = "スタッフルームの流しの下の戸棚にあります";
          break;
      }
      break;
    case 'roomkey':
      var image = '画像URL';
      switch (name) {
        case 'eng':
          var r_text = "Which key corresponds to each room?";
          var exp_text = "Correspondence of the key of each room is as this photo";
          break;
        case 'jpn':
          var r_text = "各部屋の鍵の対応";
          var exp_text = "各部屋の鍵の対応は写真の通りです";
          break;
    
      }
      break;
    case 'language':
      var image = '画像URL';
      var r_text = "Notification";
      switch (name) {
        case 'eng':
          var exp_text = "English-Mode was selected.";
          break;
        case 'jpn':
          var exp_text = "日本語モードが選択されました.";
          break;
      }
      break;
  }
  // 質問に応じたアタッチメントの定義
  var response = {
    "title": r_text,
    "text": exp_text,
    "image_url": image,
    "color": "#ffa500"
  };
  // 送信されるメッセージの定義
  var new_rep = {
    "text": head_text,
    "attachments": [
      response,
      quest_attachment
    ]
  };
  
  // 「終了」という選択肢が選ばれた時のみ異なる処理をしてボタンを消す(ボタン無しメッセージで上書きする)
  if (value == "quit"){
    // 終了時
    var reply = {
      "attachments": [
        {
          "text": "Clean the room and have a nice day !!",
          "color": "00bfff"
        }
      ]
    };
    return ContentService.createTextOutput(JSON.stringify(reply)).setMimeType(ContentService.MimeType.JSON);
  } else{
    // それ以外の場合は選択肢メッセージを出し続ける
    return ContentService.createTextOutput(JSON.stringify(new_rep)).setMimeType(ContentService.MimeType.JSON);
  }
  
}
```

</div></details>

以降アプリ①と共通する説明は省略します。

#### ペイロードの取り出し
ボタンをユーザーがタップすると、ボタンメッセージに付随した情報(`"actions"`内の要素)が送信されます。以下の部分では受信系列から`"name"`と`"value"`の要素を取り出しています。

```javascript
// ペイロード部分の取り出し
var payload = JSON.parse(e["parameter"]["payload"]);
var name = payload["actions"][0]["name"];
var value = payload["actions"][0]["value"];
```

#### 画像をアタッチメントに添付
`"image_url"`という要素が増えてます!! ここには先ほど[Google Driveの項で用意した画像URL](#参照用urlの作成)を入れます。これによりGASが直接画像を扱うことなく、**Slack App側でURLから画像を取得**して表示することができます。

```javascript
// 画像の参照URLの指定
var image = '画像URL';

// 質問に応じたアタッチメントの定義
var response = {
   "title": r_text,
   "text": exp_text,
   "image_url": image,
   "color": "#ffa500"
};
```
参考

- [GoogleAppsScript(GAS)からSlackに画像を投稿する](https://qiita.com/sublimer/items/2bf030248ab69e32b1f8)

#### メッセージ例
こんな感じで表示されます。かっこいいでしょ。
**選択肢を選ぶごとに前のメッセージに上書き**されて表示されます。
<img width="529" alt="スクリーンショット 2019-06-27 17.59.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/73ace854-9081-dad1-6d6b-bfe1f4a1da2d.png">

-------
### Webアプリ化
アプリ①とアプリ②をWebアプリとして公開し、**公開URL**をそれぞれ取得します。こちらのサイトがまたまた参考になります。

- [Google Apps Scriptで簡単にWebアプリケーションを公開する方法](https://www.indetail.co.jp/blog/181219/)


`https://script.google.com/macros/s/hogehogehogehogehoge/exec`のような形で得られるはずです。


# Slack Appの設定(後半)
GASの設定が終わり、Webアプリケーションとしての**公開URL**をゲットしました。しかしこのままではSlack側での設定が終わっていません。**スラッシュコマンド**と**Interactive Components**の設定を行います。

### スラッシュコマンドの設定
再びapiメニューよりFeaturesの**Slash Command**を選択し、**Create New Command**を選択、コマンドの設定を行います。

![スクリーンショット 2019-06-28 4.05.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/4c42020d-dda3-2905-0158-938775de06f4.png)

<dl>
    <dt>command</dt>
    <dd>コマンドの名前を決めます。スラッシュに続く任意の名前をつけましょう。ここでは<code>\gomibot</code>としました。</dd>
    <dt>Request URL</dt>
    <dd><b>アプリ①のURL<\b>を入れます。</dd>
    <dt>Short Description</dt>
    <dd>短い説明を入れます。</dd>
    <dt>Usage Hint</dt>
    <dd>今回はスラッシュコマンド単体で使う前提なのでなくてもいいです。</dd>
</dl>

### Interactive Componentsの設定
同じくFeaturesの**Interactive Components**を選択し、**Interactivity**の**Request URL**に**アプリ②の公開URL**を入れます。

![スクリーンショット 2019-06-28 3.59.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/5dbfc839-22f5-24f0-be12-1b0ca372b8d7.png)

# 完成！
以上でSlack App, Google Apps Script, Googleドライブの設定が全て完了です！
できたbotに質問しまくってみましょう！
今回のまとめは、

- **Slack側でスラッシュコマンドを受け付けて、GASのURLを叩く**
- **ボタンや画像を含んだアタッチメント付きメッセージデータをGASから送信する**
- **利用者の押したボタンに応じた応答をGASから行う**
- **利用者自身にしか見えないメッセージにしてチャンネルを汚さず利用できる**
- **Google Drive上の画像を取得してSlackで表示する**

ということがSlack App + GASで実現できました。この基本的なbotを応用すれば、アタッチメント内のコンテンンツを変えたり、Googleカレンダーやスプレッドシートをさらに加えるなどで幅広い活用ができると思います。調べていてすごい記事があったので貼っておきます。

- [GASを用いてSlack上で購買を構築，運営した(Interactive Message機能追加編)](https://qiita.com/zensai3805/items/9a2a78312c4622a9dc37)

以上でこの記事は終わりです。読んでくださりありがとうございました！

# 番外編 \: 詰まったポイント
### GASプロジェクトのバージョンアップ
コードを変更した際に、アプリケーションとして変更を反映するには「ウェブアプリケーションとして導入」をする必要がありますが、ここでバージョンを「**New**」にしないと**反映されない**という罠が！！
しかも**デフォルトでは現在のバージョンのまま更新**という形になります。
<img width="478" alt="スクリーンショット 2019-06-28 13.21.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310299/31de214a-4042-96e4-c253-714fabee2385.png">

ここが新しいバージョン更新されていないと、いくらコードを変えてもエラーメッセージの内容が変わらない、なんていう泥沼にハマります。試行錯誤で作っていたときにほんとに困りました。
こちらの記事が見つからなかったら今でも詰まってた可能性が高いです。

- [GAS利用時の落とし穴7選](https://qiita.com/kanchan-1996/items/dfb9390fa8c6c2c7322f)

更新のたびにバージョンアップしてたらバージョンがめちゃくちゃ大きな数字になりそうですね。