こんばんは、SWX3人目の熊谷(悠)です。
Google Apps Script(以下、GAS)でSlackに来ていた質問を一覧にするBotを作成した時の話です。
※約2年前に作成して以降、一切手を加えずに現在も毎日動いているBotなので現在は作り方が違うところや抜けなどあるかもしれません、ご承知おきください。
経緯
弊部署では、AWS運用代行サービスのオーナーとして社内から質問が沢山飛んできます。
これを、翌日朝にチーム内で質問を確認し、意見を合わせてから回答するようにしています。
その都度反応して、本来その日こなす筈だったタスクが遅れてしまう・予定が崩れてしまうのを避けるために、今日入ってきた仕事は明日やるという「マニャーナの法則」を取り入れています。
人によって回答内容が変わったり、回答出来る人が偏ったりしないようにするためでもあります。
問題
上項の運用をする中で以下のような場合に確認が漏れてしまい、回答を催促されるという問題が度々発生していました。この問題を解決する為のBotです。
- そもそも質問が他のメッセージで埋まる・流れてしまう
- 回答後、しばらく経ってからスレッドの中で再度質問される
システム概要
来た質問を一覧に纏めてくれるBotです。
機能一覧
#msp-questionに投稿されたメッセージの中に@mspが含まれている場合、:ashita-miru:を追加する。メッセージに
:sumi:を追加すると、Bot Userが追加していた:ashita-miru:を削除する。毎日10:25に
#msp-questionで:ashita-miru:が付いているメッセージのリンクの一覧を、#msに投稿する。Botユーザーが
#msに投稿した、メッセージリンクの一覧を毎日12:00-13:00の間に削除する。

アーキテクチャ
SlackとGASのみのシンプルな設計です。

Slack設定
Slack Botユーザーの作成方法について本稿では取り扱いません。
なお、投稿したいチャンネルと質問が来るチャンネルに作成したBotユーザーをメンバーとして追加する必要があります。
スコープ
今回Botユーザーに必要なスコープは下記の通りです。
Bot Token Scopes
User Token Scopes
Event Subscriptions
Event Subscriptionsを開く
Enable EventsをONに切り替え
Request URL に後項のGASプロジェクトの公開時に作成されるWebhook URLを入力
※エンドポイントはチャレンジ値で応答する必要があるため、Request URLの検証時のみ、doPost関数の戻り値は以下のようにしてください。
function doPost(e){
return ContentService.createTextOutput(json.challenge);
}

GAS設定
※本稿でのGASはクラシック エディタを使用しています。
ブラウザからGoogle Apps Scriptへアクセス
プロジェクト作成
「新しいプロジェクト」ボタンをクリックして新しいプロジェクトを作成

コード実装
「コード.gs」に後項のコードを記載
保存
メニューバーの「無題のプロジェクト」から名前を変更
「ファイル」>「保存」を選択し、プロジェクトを保存

プロパティ設定
※トークンがハードコードで問題無い場合、本設定は不要です。
「ファイル」>「プロジェクトのプロパティ」を選択し、ウィンドウを開く

「スクリプトのプロパティ」タブを開く
作成したSlack Botユーザーの「OAuth Token」を任意のプロパティ名で追加
「保存」ボタンをクリック

プロジェクトの公開
「公開」>「ウェブアプリケーションとして導入」を選択し、ウィンドウを開く

「Who has access to the app(アプリケーションにアクセスできるユーザー)」は
「Anyone, even anonymous(全員{匿名ユーザー含む})」を選択し、「Deploy」クリック

承認を求められるので「許可を確認」をクリック
Googleアカウントへのアクセスリクエストに「許可」をクリック
Current web app URLとして、作成されたWebhook URLが表示されるので、Slack BotユーザーのEvent SubscriptionsのRequest URLに設定
トリガー設定
GASダッシュボード画面(プロジェクト編集画面から画面左上の青い右矢印のアイコンをクリックして遷移)を開く
作成したプロジェクトの三点リーダーをクリックし、「トリガー」を選択

「+ トリガーを追加」ボタンをクリック

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

コード
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=' + token + '&channel=' + item.channel + '×tamp=' + 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=' + token + '&channel=' + item.channel + '×tamp=' + item.ts + '&name=' + reaction_emoji;
return postRequest(url);
}
function searchMessages(query){
var token = PropertiesService.getScriptProperties().getProperty('SLACK_USER_ACCESS_TOKEN');
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');
const CHANNEL_ID = 'IJKL56789';
var encode_massage = encodeURIComponent(massage);
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=' + token + '&channel=' + channel +'&ts=' + ts + '&pretty=1';
return postRequest(url);
}
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) {
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) {
var response = removeReaction(event.item);
} else if (event.reaction.indexOf(DETECTION_REACTION_KAKUNINZUMI) >= 0) {
var response = removeReaction(event.item);
}
}
}
return response.getResponseCode();
} catch (ex) {
console.log('エラー発生');
}
}
function postQuestionList(){
try {
var query = 'in%3A%23ms%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) {
console.log('エラー発生');
}
}
function deleteQuestionList(){
try {
var query = 'in%3A%23ms%20from%3A%40question_list%20after%3Ayesterday';
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) {
console.log('エラー発生');
}
}
function deleteTrigger(targetHandlerFunction){
var triggers = ScriptApp.getProjectTriggers();
for (let trigger of triggers) {
if (trigger.getHandlerFunction() === targetHandlerFunction) {
ScriptApp.deleteTrigger(trigger);
}
}
}
function setTrigger(){
const TRIGGER_FUNCTION = 'postQuestionList';
var targetDate = new Date();
deleteTrigger(TRIGGER_FUNCTION);
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が動作しない事はありませんでした。
- ただし、2~3ヵ月に1日くらいの頻度で、既にリアクション削除済みのメッセージも取得してきたりします。これはBotだからとかではなく、手動でSlack内検索を行っても同じ結果が返ってくるのでSlackの検索APIの問題だと思われます。(想像ですが、キャッシュが残っているとかでしょうか?)
参考
Slack API
API Event Types message event message.channels event
reactions.add reactions.remove
GAS Reference
Class UrlFetchApp Class HTTPResponse
記事
SlackでリアクションBotをつくろう!(GAS)-Qiita GASでログ出力する2つの方法(Logger.logとconsole.log)の紹介と使い分け Google Apps script(GAS)でLine bot開発中にハマったこと 【LINE Botの作り方】Messaging API × GAS(Google Apps Script)でおうむ返しボットを作成する | TAKEIHO GASのトリガーによるプログラムの自動実行 #2