Slackで来るメンションをGoogle Apps Scriptで一覧化した

Google Apps Script（以下、GAS）でSlackに来ていた質問を一覧にするBotを作成した時の話です。
手順やコードは記事の後ろの方に載せています。
※約２年前に作成して以降、一切手を加えずに現在も毎日動いているBotなので現在は作り方が違うところや抜けなどあるかもしれません、ご承知おきください。

# 経緯
弊チームでは、サービスを一つ運用しており、そのオーナーとして関係各所から質問が沢山飛んできます。
これを、翌日朝にチーム内で質問を確認し、意見を合わせてから回答するようにしています。
その都度反応して、本来その日こなす筈だったタスクが遅れてしまう・予定が崩れてしまうのを避けるために、今日入ってきた仕事は明日やるという「マニャーナの法則」を取り入れています。
まだ色々と未成熟なため、人によって回答内容が変わったり、回答出来る人が偏ったりしないようにするためでもあります。チームの方針をその場で決められる場でもあります。

## 問題
上項の運用をする中で以下のような場合に確認が漏れてしまい、回答を催促されるという問題が度々発生していました。この問題を解決する為のBotです。
- そもそも質問が他のメッセージで埋まる・流れてしまう
- 回答後、しばらく経ってからスレッドの中で再度質問される

# システム概要

チームへ来た質問を一覧に纏めてくれるBotです。

## 例
本稿では、以下の命名を例として記載しています。

質問を投稿するチャンネル：`#question-channel`
質問一覧を投稿するチャンネル：`#question-list`
特定の文言（ユーザーグループメンション）：`@hoge-team`
追加リアクション：`:ashita-miru:`
削除リアクション：`:sumi:`

## 機能一覧
1. 特定のチャンネルで（例`#question-channel`）投稿されたメッセージの中に特定の文言（例`@hoge-team`）が含まれている場合、投稿されたメッセージに指定のリアクション（例`:ashita-miru:`）を追加する。

2. メッセージに`:sumi:`リアクションを追加すると、Bot Userが追加していた`:ashita-miru:`リアクションを削除する。

3. 毎日10:25に特定のチャンネル（例`#question-channel`）で特定のリアクション（例`:ashita-miru:`）が付いているメッセージのリンクの一覧を、指定したチャンネル（例`#question-list`）に投稿する。

4. Botユーザーが`#question-list`に投稿した、メッセージリンクの一覧を毎日12:00-13:00の間に削除する。

![2022-02-28_10h38_53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/e1ee7142-5609-0d6b-3286-25f1bbf4f64e.png)

## アーキテクチャ
SlackとGASのみのシンプルな設計です。
![アーキテクチャ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/a2793dc8-bbb0-763f-2b93-e5dc84d6d384.png)

# Slack設定
Slack Botユーザーの設定については以下の記事をご参照ください。
投稿したいチャンネルと質問が来るチャンネルに作成したBotユーザーをメンバーとして追加する必要があります。

https://qiita.com/hiren/items/c8ffa3f9de58b80ba5da

## スコープ
今回Botユーザーに必要なスコープは下記の通りです。

Bot Token Scopes
- [channels:history](https://api.slack.com/scopes/channels:history)
- [chat:write](https://api.slack.com/scopes/chat:write)
- [chat:write.customize](https://api.slack.com/scopes/chat:write.customize)
- [reactions:read](https://api.slack.com/scopes/reactions:read)
- [reactions:write](https://api.slack.com/scopes/reactions:write)

User Token Scopes
- [search:read](https://api.slack.com/scopes/search:read)

## Event Subscriptions
Event Subscriptionsを開く
Enable EventsをONに切り替え
Request URL に後項のGASプロジェクトの公開時に作成されるWebhook URLを入力
※エンドポイントはチャレンジ値で応答する必要があるため、Request URLの検証時のみ、doPost関数の戻り値は以下のようにしてください。

```javascript:コード.gs
function doPost(e){
  return ContentService.createTextOutput(json.challenge);
}
```
![2022-02-27_23h37_58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/02e76410-fbac-e47b-7ab8-803e160b2756.png)


# GAS設定

## 注意事項
本稿でのGASはクラシック エディタを使用しています。

会社組織で管理されているGoogle Workspaceだと良くある事なのですが、組織管理のユーザーが作成したリソースは組織外からはアクセス出来ないように組織全体のポリシーで制限されている場合が多いです。
Slackからのアクセスは組織外からのアクセスに該当するため、匿名ユーザーを含む全員がアクセス可能な権限に変更しても組織全体のポリシーで弾かれてしまいます。

この制限を回避するために、フリーのGoogleアカウントを取得しています。

## プロジェクト作成
ブラウザから[Google Apps Script](https://script.google.com/home)へアクセス
「新しいプロジェクト」ボタンをクリックして新しいプロジェクトを作成
![2022-02-27_22h14_37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/3e141af1-b639-3346-4f2b-4db142724be4.png)

## コード実装
「コード.gs」に後項のコードを記載

## 保存
メニューバーの「無題のプロジェクト」から名前を変更
「ファイル」＞「保存」を選択し、プロジェクトを保存
![2022-02-27_22h28_24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/443478c4-2154-b1aa-07a4-aa35f0361466.png)

## プロパティ設定
※トークンがハードコードで問題無い場合、本設定は不要です。
「ファイル」＞「プロジェクトのプロパティ」を選択し、ウィンドウを開く
![2022-02-27_22h44_43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/01141dc7-0cb8-1b10-328b-813c9b6a9ac5.png)

「スクリプトのプロパティ」タブを開く
作成したSlack Botユーザーの「OAuth Token」を任意のプロパティ名で追加
「保存」ボタンをクリック
![2022-02-27_22h45_58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/0029c5c3-0077-3585-a535-d898fdb31698.png)

## プロジェクトの公開
「公開」＞「ウェブアプリケーションとして導入」を選択し、ウィンドウを開く
![2022-02-27_22h39_49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/81a9a395-ebc4-ead6-83c3-536054a8fbf0.png)

「Who has access to the app（アプリケーションにアクセスできるユーザー）」は
「Anyone, even anonymous（全員｛匿名ユーザー含む｝）」を選択し、「Deploy」クリック
![2022-02-27_22h41_16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/08f9fa9c-edc2-d5e6-1dc8-c6fc241826ee.png)

承認を求められるので「許可を確認」をクリック
Googleアカウントへのアクセスリクエストに「許可」をクリック
Current web app URLとして、作成されたWebhook URLが表示されるので、Slack BotユーザーのEvent SubscriptionsのRequest URLに設定

## トリガー設定
GASダッシュボード画面（プロジェクト編集画面から画面左上の青い右矢印のアイコンをクリックして遷移）を開く
作成したプロジェクトの三点リーダーをクリックし、「トリガー」を選択
![2022-02-27_23h44_17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/65a06389-bd3c-c339-af8f-c0f98799849e.png)

「＋　トリガーを追加」ボタンをクリック
![2022-02-27_22h21_04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/e91287b7-6dda-a1ff-e466-e758b8f5ba51.png)

以下の設定でトリガーを２つ追加

※毎日実行できる日付ベースのトリガーは正確な時間指定ができず、指定した時刻のどこかで関数が実行されます。
※特定の日時を指定し、一度のみ実行するトリガーは時分単位まで指定できます。
| 実行する関数 | イベントのソース | 時間ベースのトリガーのタイプ | 時刻 |
|:-:|:-:|:-:|:-:|
|setTrigger|時間主導型|日付ベースのタイマー|postQuestionListの実行時間に被らない時間帯|
|deleteQuestionList|時間主導型|日付ベースのタイマー|postQuestionListの実行時間より後の投稿を消したい時間帯|

![2022-02-28_00h57_41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269716/f2a22f23-62b2-d429-9ee2-b33757e21e1b.png)


# コード
```javascript:コード.gs
function postRequest(url){
  var method = 'post';
  var payload = {};
  var params = {
    'method': method,
    'payload': payload
  };
  var response = UrlFetchApp.fetch(url, params);
  return response;
}

function addReaction(item){
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');
  var reaction_emoji = 'ashita-miru';

  //var url = 'https://slack.com/api/reactions.add?token=xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx&channel=ABCD01234&timestamp=1234567890.012345&name=ashita-miru'
  var url = 'https://slack.com/api/reactions.add?' + 'token=' + token + '&channel=' + item.channel + '&timestamp=' + item.ts + '&name=' + reaction_emoji;
  return postRequest(url);
}

function removeReaction(item){
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');
  var reaction_emoji = 'ashita-miru';

  //var url = 'https://slack.com/api/reactions.remove?token=xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx&channel=ABCD01234&timestamp=1234567890.012345&name=ashita-miru'
  var url = 'https://slack.com/api/reactions.remove?' + 'token=' + token + '&channel=' + item.channel + '&timestamp=' + item.ts + '&name=' + reaction_emoji;
  return postRequest(url);
}

function searchMessages(query){
  //OAuth Scope「search:read」のみBot Token Scopesではないため、トークンが異なる
  //var token = PropertiesService.getScriptProperties().getProperty('SLACK_USER_ACCESS_TOKEN');
  //FIXME:何故か設定したGASのプロパティから読み出せ無かった為一旦ハードコード
  var token = 'xoxp-xxxxxxxxxx-xxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

  //var url = https://slack.com/api/search.messages?token=xoxp-xxxxxxxxxx-xxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&query=in%3A%23question-channel%20has%3A%3Aashita-miru%3A&pretty=1
  var url = 'https://slack.com/api/search.messages?' + 'token=' + token + '&query=' + query + '&pretty=1';
  return postRequest(url);
}

function postMessage(massage){
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');
  //投稿できるのはアプリを追加したチャンネルとDMのみ
  const CHANNEL_ID = 'IJKL56789';

  //URL等がメッセージに含まれている場合エンコードしないとpost時にエラーになる
  var encode_massage = encodeURIComponent(massage);
  //var url = https://slack.com/api/chat.postMessage?token=xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx&channel=IJKL56789&text=testmassage&pretty=1
  var url = 'https://slack.com/api/chat.postMessage?' + 'token=' + token + '&channel=' + CHANNEL_ID + '&text=' + encode_massage + '&pretty=1';
  return postRequest(url);
}

function deleteMassage(channel,ts){
  var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN');

  //var url = https://slack.com/api/chat.delete?token=xoxb-xxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxx&channel=IJKL56789&ts=1234567890.012345&pretty=1
  var url = 'https://slack.com/api/chat.delete?' + 'token=' + token + '&channel=' + channel +'&ts=' + ts + '&pretty=1';
  return postRequest(url);
}

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

//Slack Botユーザーが参加しているチャンネルで何らかのイベントが発生した時、Webhook経由で実行される関数
function doPost(e){
  const CHANNEL_ID = 'ABCD01234';
  const GROUP_ID = 'EFGH56789';
  const DETECTION_STRING = GROUP_ID;
  try {
    var json = JSON.parse(e.postData.getDataAsString());
    var event = json.event;

    //投稿されたメッセージ内に検知文字列が含まれていた場合に明日確認する旨のリアクションを追加する
    if (event.type === 'message') {//イベントがメッセージの投稿であるか
      if (event.channel === CHANNEL_ID) {//該当チャンネルであるか
        if (event.text.indexOf(DETECTION_STRING) >= 0) {//文字列が見つからなかったら-1
          var response = addReaction(event);
        }
      }
    }

    //確認した旨のリアクションが追加された時、メッセージに付けていた明日確認する旨のリアクションを削除する
    const DETECTION_REACTION_SUMI = 'sumi';
    const DETECTION_REACTION_KAKUNINZUMI = 'kakuninzumi';
    if (event.type === 'reaction_added') {//イベントがリアクションの追加であるか
      if (event.item.channel === CHANNEL_ID) {//該当チャンネルであるか
        if (event.reaction.indexOf(DETECTION_REACTION_SUMI) >= 0) {//リアクションが見つからなかったら-1
          var response = removeReaction(event.item);
        } else if (event.reaction.indexOf(DETECTION_REACTION_KAKUNINZUMI) >= 0) {//リアクションが見つからなかったら-1
          var response = removeReaction(event.item);
        }
      }
    }
    return response.getResponseCode();
//    return ContentService.createTextOutput(json.challenge);//Event SubscriptionsにRequest URLを検証させるときだけ必要
  } catch (ex) {
    Logger.log('エラー発生');//直接実行用
    console.log('エラー発生');//トリガー用
  }
}

//GASのトリガーで実行される関数
//質問一覧を投稿する
function postQuestionList(){
  try {
    //var query = 'in:#question-list has::ashita-miru:';
    var query = 'in%3A%23question-list%20has%3A%3Aashita-miru%3A';
    var response = searchMessages(query);

    var json = JSON.parse(response.getContentText());
    var messages = json.messages.matches;

    var massage_link = '';
    for (let i = 0; i < messages.length; i++) {
      let link = json.messages.matches[i].permalink;
      massage_link += link + '\n';
    }

    const GROUP_ID = 'EFGH56789';
    if (messages.length === 0) {
      post_massage = '全未回答メッセージ確認完了！\nやることリストから1つ消えました！';
    } else {
      post_massage = '<!subteam^'+GROUP_ID+'> 未回答の質問一覧はこちら！\n' + '```' + massage_link + '```';
    }

    return postMessage(post_massage);
  } catch (ex) {
    Logger.log('エラー発生');//直接実行用
    console.log('エラー発生');//トリガー用
  }
}

//GASのトリガーで実行される関数
//投稿した質問一覧を削除する
function deleteQuestionList(){
  try {
    //Botユーザー名がスペース区切り「@question list」だとsearch.messagesのqueryで検索文字が@question + listになってしまったので、Botユーザー名を「@question-list」へ変更した
    //追記：実はスペース区切りでもアンダースコアにすると検索ができた
    //var query = 'in:#question-list from:@question_list after:yesterday';
    var query = 'in%3A%23question-list%20from%3A%40question_list%20after%3Ayesterday';
    
    //https://slack.com/api/search.messages?token=xoxp-xxxxxxxxxx-xxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&query=in%3A%23question-list%20from%3A%40question-list%20after%3Ayesterday&pretty=1
    var response = searchMessages(query);

    var json = JSON.parse(response.getContentText());
    var messages = json.messages.matches;
    
    for (let i = 0; i < messages.length; i++) {
      let channel_id = json.messages.matches[i].channel.id;
      let ts = json.messages.matches[i].ts;
      var response = deleteMassage(channel_id,ts);
    }
    return response;
  } catch (ex) {
    Logger.log('エラー発生');//直接実行用
    console.log('エラー発生');//トリガー用
  }
}

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

//トリガーを削除する
function deleteTrigger(targetHandlerFunction){
  var triggers = ScriptApp.getProjectTriggers();//現在のプロジェクトで設定済みのすべてのトリガーを取得
  for (let trigger of triggers) {
    //トリガーに設定されている関数とtargetHandlerFunctionが一致する場合にトリガーを削除
    if (trigger.getHandlerFunction() === targetHandlerFunction) {
      ScriptApp.deleteTrigger(trigger);
    }
  }
}

//GASのトリガーで実行される関数
//トリガーを設定する
function setTrigger(){
  const TRIGGER_FUNCTION = 'postQuestionList';
  var targetDate = new Date();

  //毎日トリガーが増え続けないよう、古いトリガーを削除
  deleteTrigger(TRIGGER_FUNCTION);

  //TRIGGER_FUNCTIONを10:25に実行するトリガーを設定
  targetDate.setHours(10);
  targetDate.setMinutes(25);
  ScriptApp.newTrigger(TRIGGER_FUNCTION).timeBased().at(targetDate).create();
}
```

# Q&A
- なんでGASにしたの？
  - 無料でサクッと組めたのが良かったからです。
  - 内部関係者からの質問だけなので、突然動かなくなっても最悪手動で確認すれば良く、深刻な問題にはならないためです。
- どうして毎日、投稿したメッセージリンクの一覧を削除しているの？
  - 通常のやり取りで使用しているチャンネルなので、Botのメッセージによって通常のメッセージが埋まるのを避けるためです。
- どうして直接`postQuestionList`関数をトリガーで設定せずに`setTrigger`関数内で設定しているの？
  - 手順内に記載の通り、日付ベースのトリガーは正確な時間指定ができません。
  - `postQuestionList`関数は、毎日10:25分丁度に動かしたかったので、「毎日一度のみ`postQuestionList`関数を実行するトリガー」を設定するトリガーを`setTrigger`関数で設定しています。
- GASが動かなくなった事はある？
  - 稼働させてから一度もGASが動作しない事はありませんでした。
  - ただし、２～３ヵ月に１日くらいの頻度で、既にリアクション削除済みのメッセージも取得してきたりします。これはBotだからとかではなく、手動でSlack内検索を行っても同じ結果が返ってくるのでSlackの検索APIの問題だと思われます。（想像ですが、キャッシュが残っているとかでしょうか？）

# 参考
## Slack API
[API Event Types](https://api.slack.com/events)
[message event](https://api.slack.com/events/message)
[message.channels event](https://api.slack.com/events/message.channels)

[reaction_added event](https://api.slack.com/events/reaction_added)

[reactions.add](https://api.slack.com/methods/reactions.add)
[reactions.remove](https://api.slack.com/methods/reactions.remove)

[search.messages](https://api.slack.com/methods/search.messages)

[chat.postMessage](https://api.slack.com/methods/chat.postMessage/test)
[chat.delete](https://api.slack.com/methods/chat.delete/test)

## GAS Reference
[Class UrlFetchApp](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app)
[Class HTTPResponse](https://developers.google.com/apps-script/reference/url-fetch/http-response)

## 記事
[SlackでリアクションBotをつくろう！（GAS）-Qiita](https://qiita.com/am10/items/b2f61e3e15e04545d647)
[GASでログ出力する2つの方法(Logger.logとconsole.log)の紹介と使い分け](https://tanuhack.com/gas-log/)
[Google Apps script(GAS)でLine bot開発中にハマったこと](https://yacoleblog.com/172)
[【LINE Botの作り方】Messaging API × GAS(Google Apps Script)でおうむ返しボットを作成する | TAKEIHO](https://takeiho.com/messaging-api-gas)
[Slack APIでメッセージの検索をしてみた（search.messages）](https://dev.classmethod.jp/articles/search-messages-with-slack-api/)
[GASのトリガーによるプログラムの自動実行 #2](https://note.com/lydiacorp/n/n031bd3847e95)