LoginSignup
14
12

More than 3 years have passed since last update.

Slackの特定の投稿をトリガーとした、サーバーレスなリマインドBOTをGoogle Apps Scriptで開発したお話

Last updated at Posted at 2019-06-09

今回はSlackに任意の文字列やユーザーから投稿がされた場合、リマインドメッセージを投げるBOTを作りました。

こんなかんじ↓
trigerHistoryDemo.gif

過去の投稿の追加報告をしたいニーズは結構あると思ったので、ここではリマインドBOTというかたちでソースコードはもちろん、Slack, Google Apps Script双方で設定したことをを共有します。

もし、Google Apps Scriptをイチから勉強したいとお考えでしたら、チュートリアル1/2(前編)から読むことをお勧めします。

前編
【入門】Google Apps Scriptチュートリアル1/2(デモ動画付き・①doGet()とdoPost()でHellow World!)

後編
【入門】Google Apps Scriptチュートリアル2/2(デモ動画付き・②コマンドを叩いてチャンネル情報を表示させる)

番外編
【入門】Google Apps Scriptチュートリアル(番外編・チャンネルの投稿内容を投稿の古い順からスプレッドシートに書き出す)

0. 目次

--設定編--
1. slackからLegacy Tokenを発行
2. slackからIncoming Webhooksを発行
3. channels.historyのパーミッションを取得

--コーディング編--
4. ソースコードとその解説(実行関数・main)
5. ソースコードとその解説(channels.historyからslackの投稿情報を取得する・getSlackLog(token, id, url, days))
6. ソースコードとその解説(取得した投稿情報から必要な情報を抽出する・parseSlackLog(array, booleanValue, keyTable, filter))
7. ソースコードとその解説(unixtimeを読みやすい書式に変換する・getMomentDateTime(unixtime, booleanValue))
8. ソースコードとその解説(リッチコンテンツ形式でSlackに投稿・sendMsg(fromChannel, destChannel, userId, posted_date, task, incomingWebhookUrl) )

1. SlackからLegacy Tokenを発行

今回は、トリガーとする投稿のチャンネルが、あるいは投稿先となるチャンネルがprivate channelである場合にも対応したいので、Legacy Tokenを使います。

というのも一般的なTokenではプライベートチャンネルは僕の環境では取得できないことが確認できているためです。(2019/06/09現在)
"一般的な"というのは、Legacy Tokenの使用はSlackでは推奨されていないためです。
2019-06-09_15h36_02.png

Legacy Tokenの取得先は https://api.slack.com/custom-integrations/legacy-tokens なので、クリックしてください。
"slack legacy token" と検索されても上のページが見つかるかと思います。

さて、ページの下方までスクロールすると、このように"Create token"の文字が確認できるので、Tokenを作りたいワークスペースの右横の"Create token"をクリックしてTokenを作りましょう。

2019-06-09_14h09_18.png

※APPを作っていない、あるいは新規に作りたいという方は、【入門】Google Apps Scriptチュートリアル1/2(デモ動画付き・①doGet()とdoPost()でHellow World!)#appの作成をご参照ください。

2. slackからIncoming Webhooksを発行

https://api.slack.com/apps からAdd features and functionalityを開いた後、Incoming Webhooksをクリックしてください。

Add features and functionalityをクリック】
2019-06-09_14h32_58.png

Incoming Webhooksをクリック】
2019-06-09_14h36_37.png

Incoming Webhooksのページに飛んだら、下方までスクロールしてください。
スクロールした後、"Add New Webhook to Workspace"をクリックして、"Copy"をクリックしてください。
2019-06-09_14h41_19.png

試しにcurlコマンドとIncoming Webhooksをご自身のターミナルに貼って叩いてみましょう。
きっと指定したチャンネルに"Hellow World"と表示されていることが確認できるはずです。
サンプル動画を貼ってみたので、併せてご参照ください。

curlIncomingWebhook.gif

3. channels.historyのパーミッションを取得

https://api.slack.com/apps から Add permission by scope or API method... を開いた後、channels.historyを選択し、Save Changesをクリックしてchannels.historyのパーミッションを取得したことを保存してください。
Add permission by scope or API method...をクリック】
2019-06-09_15h45_31.png

Save Changesをクリック】
2019-06-09_15h48_07.png

以上、ここまでがやらなければならない設定となります。
続いてコーディングに移ります。
Google Driveの好きなフォルダーを開いてScriptエディタを立ち上げましょう。

4. ソースコードとその解説(実行関数・main)

まずは時間指定で起動する実行関数のmain関数です。
また、コードをコピペする前に、moment.jsの登録を忘れないうちにしてしまいましょう。
登録方法は下記の動画を参考にしてください。
appendix_03_add_libralies.gif

また、登録に使うkeyも掲載します。

/*

library key;
moment.js
MHMchiX6c1bwSqGM1PZiW_PxhMjh3Sh48

*/

本BOTのリマインドメッセージを送るかどうかの判断基準は以下の2点です。

・特定の投稿者が投稿したことが確認できた場合
・特定の文字列が確認できた場合

function main() {
   //確認対象のユーザーのID
   var keyTable = [
     "<USER_ID1>", //aaaaaaaaa
     "USER_ID2",   //bbbbb
     "<USER_ID3>", //ccccc
   ];
  //channels.historyで投稿情報を取得する期間(日単位)
    var period = 1;
    //解説:4-1. 確認対象の文字列
    var filter = /.*(はじめます。).*/;


    /*-------Args----------------------------------------------*/
    const SLACK_LEGACY_TOKEN = "xoxp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
    // cf. channel url = "https://<YOUR_SLACK_TEAM>.slack.com/messages/<YOUR_CHANNEL_ID>"";
  //channels.historyのサンプル
    //https://slack.com/api/channels.history?token=<Token or Legacy Token>&channel=<Channel ID>
    const BASE_URL = "https://slack.com/api/channels.history?";
    const INCOMING_WEBHOOK_URL = 'https://hooks.slack.com/services/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

  //channels.historyで取得するチャンネルの名前
    const FROM_CHANNEL = "<FROM_CHANNEL>";
    const FROM_CHANNEL_ID = "<FROM_CHANNEL_ID>";

  //Incoming_Webhooksで投稿するチャネルの名前
    const DEST_CHANNEL = "<DEST_CHANNEL_ID>";

    //投稿日時のフォーマット(年有り)
    var isYear = true;

    //投稿日時のフォーマット(年無し)
    //var isYear = false;


    /*---------------------------------------------------------*/ 

  //channels.historyで特定のチャンネルへの投稿情報をmessagesに格納し、reverse()で日付が若い順とする
    var messages = JSON.parse(
        getSlackLog(SLACK_LEGACY_TOKEN, FROM_CHANNEL_ID, BASE_URL, period)
    )
    .messages
    .reverse();

    if ( !messages ) {
        return;
    }

    var logs = parseSlackLog(messages, isYear, keyTable, filter);
    //Logger.log("logs: %s", logs.length);

    /*
    * var logs = [
    *     {"userId": "aaa", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "xxxxxxxxx",},
    *     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "yyyyyyyyy",},
    *     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "zzzzzzzzz",},
    *   ]; 
    */


    for ( var i = 0; i<logs.length; i++  ) {
        sendMsg(FROM_CHANNEL, DEST_CHANNEL, 
               logs[i]["userId"], logs[i]["posted_date"], logs[i]["task"], INCOMING_WEBHOOK_URL);    
    }
}

ソースコードの解説に移ります。

4-1. 確認対象の文字列

var filter = /.*(はじめます。).*/;

ここでははじめますを含む文字列としました。
".*"は任意の文字列という正規表現です。
"/"が正規表現であることのフラグとなっています。
なお、Google Apps Scriptでの正規表現の書き方はこのようなものとなります。

var string = "これからxxx会議をはじめます。";
var filter = /.*(はじめます。).*/;

function getMatchSample(string, filter){
    //stringにfilterが含まれる場合、trueを返り値とする。
    if ( string.match(filter) ) {
        return true;
    }
  //stringにfilterが含まれない場合、falseを返り値とする。
    else {
        return false;
    }
}

5. ソースコードとその解説(channels.historyからslackの投稿情報を取得する・getSlackLog(token, id, url, days))

/**
* getSlackLog Function
*
* properties in the objects/rows
* @param {string} token
* @param {string} id  
* @param {string} url
* @param {number} days 
*/
function getSlackLog(token, id, url, days) {
    var payload = {
      // Slack Token
      'token': token,
      // Channel ID
      'channel': id,
      // 24 Hours/day * 1 days
      'oldest': parseInt( new Date() / 1000 ) - (60 * 60 * 24 * days)
    }

    // URL Of Param 
    var params = [];
    //解説:5-1. 連想配列payloadの値を結合する
    for (var key in payload) {
        params.push(key + '=' + payload[key]);
    }
    //解説:5-1. 連想配列payloadの値を結合する
    var requestUrl = url + params.join('&');
    return UrlFetchApp.fetch(requestUrl)
}

ソースコードの解説に移ります。

5-1. 連想配列payloadの値を結合する

ここでは、連想配列(pythonなどでは辞書型といいますよね!?)であるpayloadからfor loopでkeyとvalueのpayload[key]を配列のparamsにpushしています。

channels.historyを取得するために適切なURLのフォーマットを生成する流れはこのようにしています。
i) keyとpayload[key]のあいだに"="をはさみ、for loopで取得したkeyとvalueをpushし続ける
ii) 全てのkeyとvalueをpushしたら、paramsのなかのvalueを"&"でつなぎ、型をarrayからstringに変換する

6. ソースコードとその解説(取得した投稿情報から必要な情報を抽出する・parseSlackLog(array, booleanValue, keyTable, filter))

/**
* parseSlackLog Function
*
* properties in the objects/rows
* @param {object}  array            - JSON.parse(getSlackLog(SLACK_TOKEN, CHANNEL_ID, REQUEST_URL)).messages.reverse();
* @param {boolean} booleanValue     - true/false 
*   if "booleanValue" is true, the format of timestamp is "YYYY/MM/DD(ddd) HH:mm:ss"
*   if not, the format is "MM/DD(ddd) HH:mm:ss"
* @params {array}  keyTable         - Slack User IDs
* @params {string}  filter         - e.g. はじめます。
* @return {array}  logs            
*   [
*     {"userId": "aaa", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "xxxxxxxxx",},
*     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "yyyyyyyyy",},
*     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "zzzzzzzzz",},
*   ];
* 
*/
function parseSlackLog(array, booleanValue, keyTable, filter) {
    //解説:6-1. 値が仕訳された配列を返り値とする 
    var logs = [];
    //解説:6-2. for loopで値を直接取り出す
    for each(var key in keyTable ) {
        //解説:6-1. 値が仕訳された配列を返り値とする 
        var log = {"userId": "", "posted_date": "", "task": "",};
        for each( var val in array ) {
            if ( val.text.match(filter) ){
         //解説:6-3. 特定の投稿者の投稿のみ取り出す
                if ( key == val.user ) {
                    // UserId
                    log["userId"] = val.user
                    // DateTime
                   log["posted_date"] = getMomentDateTime(val.ts, booleanValue);
                    // task
                    log["task"] = val.text 
                    logs.push(log);
                }

            }

        }
    }
    //Logger.log(logs);
    return logs;
}

ソースコードの解説に移ります。

6-1. 値が仕訳された配列を返り値とする

返り値はこのように配列の中に連想配列を入れた形を採用しました。

var logs = [
     {"userId": "aaa", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "xxxxxxxxx",},
     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "yyyyyyyyy",},
     {"userId": "bbb", "posted_date": "YYYY/MM/DD(ddd) HH:mm:ss", "task": "zzzzzzzzz",},
];

たとえば、配列の一番最初の投稿者のuserIdを取りたい場合はこのように取り出します。

var firstId = logs[0]["userId"]

6-2. for loopで値を直接取り出す

for loopにeachを付け足す書き方はいろんなところで見受けられるのですが、StackOverFlowでイイ感じのサンプルコードを見つけたので拝借します。
公式docsでは見当たらなかった、、、。

In Google Apps Script:
When using "for (var item in itemArray)",
'item' will be the indices of itemArray throughout the loop (0, 1, 2, 3, ...).

When using "for each (var item in itemArray)",
'item' will be the values of itemArray throughout the loop ('item0', 
'item1', 'item2', 'item3', ...).

6-3. 特定の投稿者の投稿のみ取り出し、連想配列にpushし続ける

これがこの関数の肝なので、簡単にここでやっていることをご説明するためにサンプルコードを書きました。
arrayとは、channels.historyで取得した値をjsonフォーマットにしたものの一部を充てています。
(説明の便宜上、値は少し整形しています。)
なお、対象ユーザーのuserIdも説明の都合上、ひとつとしていますが、実際は複数個あることを想定しています。
そのため、keyTableと配列で定義し、for loopで一つずつkeyとして取得しています。
これだとネストが深く、ややこしくなるので、今回はkeyひとつとし、コードを簡略化させました。

function parseLogsSample() {
    //確認対象のユーザーのID
    var key = "<USER_ID1>", //aaaaaaaaa
    var hasj = [
        //var val = array[0];
        {
            client_msg_id=cea471e3-90c9-45ef-b488-0a72f5bf00fa, 
            text=これから会議をはじめます。, 
            type=message, 
            user=<USER_ID1>,       //userId
            ts=1560067405.000200  //timestamp
        }, 
         //var val = array[1];
        {
           client_msg_id=160fbf2d-d356-42b6-a5aa-379d503e3782, 
           text=これから宿題をはじめます。, 
           type=message, 
           user=<USER_ID2>, 
           ts=1560068635.000600
        }
    ]
    var log = {"userId": "", "posted_date": "", "task": "",};
    for each( var val in array ) {
        if ( val.text.match(filter) ){
         //解説:6-3. 特定の投稿者の投稿のみ取り出す
            ///var val == array[0];である場合、val.user == "<USER_ID1>";となるので、true
            if ( key == val.user ) {
                //for loopの手前でlogをこのように初期化し、項目ごとに値をセットしていき、userId, posted_date, taskの3つをlogにそれぞれ格納した後、logをlogsにpush
                //var log = {"userId": "", "posted_date": "", "task": "",};

                // UserId
                log["userId"] = val.user
                // DateTime
               log["posted_date"] = getMomentDateTime(val.ts, booleanValue);
                // task
                log["task"] = val.text 
                logs.push(log);
            }    
        }    
    }
}

7. ソースコードとその解説(unixtimeを読みやすい書式に変換する・getMomentDateTime(unixtime, booleanValue))

""
"YYYY/MM/DD(ddd) HH:flag_mm:ss"を読みやすい書式として変換します。
年までリマインドメッセージに含む必要はない場合、isYear(booleanValue)をfalseに書き換えてください。

/**
* getMomentDateTime Function
*
* properties in the objects/rows
* @param  {string}  unixtime                    - e.g. 1557197740.001300
* @param  {boolean} booleanValue                - timestamp with year, or not
* @return {string}  'YYYY/MM/DD(ddd) HH:mm:ss' or 'MM/DD(ddd) HH:mm:ss'
*/
function getMomentDateTime(unixtime, booleanValue) {
  //解説:7-1. めんどうな時間の書式変更をmoment.jsであっというまにおこなう
    // Register lang:ja
    Moment.moment.lang(
      'ja', {
        weekdays: ["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"],
        weekdaysShort: ["日","月","火","水","木","金","土"],
      }
    );
    if ( booleanValue ) {
        //booleanValue == true, YYYY有り
        return Moment.moment(unixtime * 1000)
            .format('YYYY/MM/DD(ddd) HH:mm:ss');
    } else {
        //YYYY無し
        return Moment.moment(unixtime * 1000)
            .format('MM/DD(ddd) HH:mm:ss');
    }

}

7-1. めんどうな時間の書式変更をmoment.jsであっというまにおこなう

Google Apps Scriptではdate()でdateオブジェクトを生成することは出来ますが、漢字で曜日を表す場合に使い勝手がイマイチだなぁと感じることがあるのではないでしょうか。。。

moment.jsを使えば、それも今日でおしまいです。
カンタンなサンプルコードを書いてみました。

function getMomentDate(xDays) {
    Moment.moment.lang(
        'ja', {
            weekdays: ["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"],
            weekdaysShort: ["日","月","火","水","木","金","土"],
         }
    );
    return Moment.moment()
            .add(xDays, 'days')
            .format('YYYY/MM/DD(ddd)');
}

このスクリプトは、起動日からx日後を'YYYY/MM/DD(ddd)'という書式で出力してくれます。

特定の期日を先ほどのような見やすい書式に成形する場合、Moment.moment()のなかには整形したいdateオブジェクトを入れればOKです。
dateオブジェクトといっても"2018/06/09"というようなかたちであれば、大丈夫です。
あとはnew Date()で出力される、Wed Feb 17 10:00:00 GMT-08:00 2018でもかまいません。

なお、Moment.moment()とformat()のあいだにadd()を付け足すことで、
Moment.moment()の引数のdateオブジェクトのx日後も出力することが可能です。

Moment.moment().add(xDays, 'days').format('YYYY/MM/DD(ddd)');

8. ソースコードとその解説(リッチコンテンツ形式でSlackに投稿・sendMsg(fromChannel, destChannel, userId, posted_date, task, incomingWebhookUrl) )

 /**
 * 
 * Post A Message with Attachments on slack
 *
 * properties in the objects/rows
 * @param {string} fromChannel         - channels.historyで投稿情報を取得したチャンネル
 * @param {string} destChannel      - <incomingWebhookUrl>で投稿するチャンネル
 * @param {string} userId        - <incomingWebhookUrl>で投稿するメッセージの宛名(SlackのUserId)
 * @param {string} posted_date        - channels.historyで取得した特定の投稿の日時
 * @param {string} task               - channels.historyで取得した特定の投稿の内容
  * @param {string} incomingWebhookUrl
 */
function sendMsg(fromChannel, destChannel, userId, posted_date, task, incomingWebhookUrl) {
   var title = "以下の業務の終了連絡を#" + fromChannel+ "に送りましたか?";
   //解説:8-1. Slackで投稿するテキストをリッチコンテンツとする
   var payload = {
      "channel": "#" + destChannel,
      "username": "New item added to reading list",
      "icon_emoji": ":grin:",
      "link_names": 1,
      "attachments":[
         {
            "fallback": "This is an update from a Slackbot integrated into your organization. Your client chose not to show the attachment.",
            "pretext": "<@" + userId + ">",
            "mrkdwn_in": ["pretext"],
            "color": "#76E9CD",
            "fields":[
                 {
                     //var title = "以下の業務の終了連絡を#" + fromChannel+ "に送りましたか?";
                     "title": title,
                     "value": task,
                     "short": false
                 },
                 {
                     "title":"投稿日時",
                     "value": posted_date,
                     "short": false
                 },
             "actions": [
               {
                   "type": "button",
                   "text": "報告はこちらまで!",
                   "url": "https://xxxxxxxxxxxxxxxxxx"
               }
           ]
        }
     ]
   };
   Logger.log("payload: %s", payload)

   var options = {
      'method': 'post',
      'payload': JSON.stringify(payload)
   };
   //Logger.log("options: %s", options);
   //return;
   return UrlFetchApp.fetch(incomingWebhookUrl,options);
 }

解説:8-1. Slackで投稿するテキストをリッチコンテンツとする

これはSlackのサンプルコードを参考にデバッグしながら作ったので、あまり詳しくないです、、、。
https://api.slack.com/incoming-webhooks#posting_with_webhooks

各パラメータだけ簡易的に解説します。

   var payload = {
      "channel": "#" + destChannel,
      "username": "New item added to reading list",
      "icon_emoji": ":grin:",
      "link_names": 1,
      "attachments":[
          {
            "fallback": "This is an update from a Slackbot integrated into your organization. Your client chose not to show the attachment.",
       //宛名(メンション)
            "pretext": "<@" + userId + ">",  
            "mrkdwn_in": ["pretext"],
            "color": "#76E9CD",
      //テキスト左側に縦線が入る
            "fields":[
                 {
                    //"title"は太字、"value"は普通のテキストスタイル
                     //どうも"title"と"value"にはメンションを飛ばすためのリンクを添付することは難しいみたいです、、。
//代替案として"actions"という機能を使ってボタンを作り、そこにリンクを添付することにしました。
                     //var title = "以下の業務の終了連絡を#" + fromChannel+ "に送りましたか?";
                     "title": title,
                     "value": task,
                     "short": false
                 },
                 {
                     "title":"投稿日時",
                     "value": posted_date,
                     "short": false
                 },
             "actions": [
               {
                   "type": "button",
                   "text": "報告はこちらまで!",
                   "url": "https://xxxxxxxxxxxxxxxxxx"
               }
           ]
        }
     ]
   };

Githubにあげているソースコードはこちら

P.S. Twitterもやってるのでフォローしていただけると泣いて喜びます:)

@gkzvoice

14
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
12