teppay’s log

*について書きます

Google Apps ScriptとSlack Event APIで体重を記録してくれるBotを作ってみた

はじめに

  • 最近顕著に太ってきた
  • お盆に帰って会ったすべての人に太ったと言われた
  • 腹がたった
  • 体重計を買った
  • ランニングを始めた
  • 効果が見えたほうが続けやすいだろうし記録したい
  • SlackBotにやってもらおう
  • はじめて作るので備忘録も兼ねて記録します
  • 改善点があれば教えてください
  • コードはGitHubにあげてあります

  • 初めてSlackBotをつくってテンション上がってたんですが後でよく考えたら,(体重の記録という部分が)車輪の再々々発明的なことをしていて若干恥ずかしい感じですが「SlackBotの作り方の例」として読んであげてくださいw

ながれ

  • Slack側: 自作Appを新規作成する
  • Slack側: 登録したAppにBot Userを追加する
  • GAS側: 最低限のEvent Handlerを実装
  • Slack側: Slackで起こったEventを外部サーバに通知してもらう設定
  • Slack側: 自作Appのインストール
  • 体重の記録機能
  • グラフ

やったこと

Slack側: 自作Appを新規作成する

まずはSlack側での操作です
Slack API: Applications | SlackここからSlack Appsを新規作成します
以下のようなダイアログでAppの名前と開発に使用する(公開しない場合は実際に使用する)Workspaceを指定します.

f:id:teppay:20180923021553p:plain

Slack側: 登録したAppにBot Userを追加する

次にBot UsersというメニューからAppにBot Userを追加する 以下のように表示名とDefault usernameというものを指定します.

f:id:teppay:20180923021632p:plain

ちなみに自分は良いのが思い浮かばなかったのでそのまま体重管理さんって名前にしました笑
今回のブログでは体重管理さんですが今後は改良を加えて健康管理さん秘書さんみたいにグレードアップしていく予定です

GAS側: 最低限のEvent Handlerを実装

今回作ったSlackのBotはバックエンドにGoogleGoogle Apps Script(以降GAS)を使用しました.
GASを使うことの最大の理由はタダであることです.WebのIDEを使用してブラウザで開発して,そのままWebサイトから実行ができます.よってサーバを自前で用意する必要があるません.素晴らしい!!

今回のSlackのBotではEvent APIと呼ばれる種類のAPIを使用します.このAPIを使用するためには最低限実装する必要のある処理があるのでまずはそれを実装します.Slack Botの種類は以下の記事にわかりやすくまとまっています

qiita.com

Event APIを使うためにはアクセス先の検証のためにurl_verificationというtypeのアクセスに適切に答える必要があります.
challangeパラメータをそのままレスポンスとして返すだけの処理を書きます. 詳しくはこのページに書いてありますが,以下のコードのコピペで動きます url_verification event | Slack

function doPost(e) {
  var postData = JSON.parse(e.postData.getDataAsString());
  var res = {};
  
  if(postData.type == 'url_verification') {
    res = {'challenge':postData.challenge}
  } 
  
  return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
}

doPostはGASのいわゆる組み込み関数のようなもので,Webアプリケーションとして動作させている場合にPOSTメソッドでのアクセスで実行される関数です.なのでdoGetもあります. ちなみにGAS自体の説明はあまりしません.
Apps Script – Google Apps Scriptにアクセスして,左側のメニューから新規スクリプトを作成するとIDEに入れます.コードをコピペして保存してください.
ここからが重要で,Scriptを保存しただけではBotのバックエンドとして動かすことはできません.
ファイル -> 版を管理を選択し,適当な名前を付けて保存します.バージョン管理になるのでGitHubのCommitメッセージ的な感じでつけると良いんだと思います.
次に,公開 -> ウェブアプリケーションとして導入を選択して,プロジェクトバージョンを最新の版にして,アプリケーションにアクセスできるユーザを「全員(匿名ユーザを含む)」にして更新します.この一通りの操作は面倒ですが,コードを変更したらその都度行ってください,
そこで表示されたURLが作ったWebアプリケーションのURLになるので,適当にメモっといてください. 下記の記事に画像つきでわかりやすくウェブアプリケーションとして導入の手順がまとまっています. Google Apps Scriptを使って簡易APIをサクッと作る

Slack側: Slackで起こったEventを通知してもらう(Event API)設定

次はSlack側のEvent APIを有効にします
左側のメニューからEvent Subscriptionsを選択し,トグルスイッチを操作してONにします.
ONになると,Request URLという欄があるのでそこに先程メモったGASのWebアプリケーションのURLを入力します. スクショのようにVerifiedとなれば1つ前で実装したurl_verificationがうまく行ったということです

f:id:teppay:20180923021643p:plain

あとは,どのEventを通知してほしいか選べば良いだけです.今回のBotはひとまずDMで送られてきたメッセージが読めればいいのでこのようにSuvscribe to Bot EventsAdd Bot User Eventからmessage.imを追加して,画面下部のSave Changesをクリックして保存します.

f:id:teppay:20180923021722p:plain

Slack側: 自作Appのインストール

いくら設定やバックエンドの処理を書いたってインストールしなければ使えないので,Workspaceにインストールします.
左側のメニューでInstall Appを選択し,Install App to Workspaceをクリックして許可すれば,最初にしていしたWorkspaceにAppがインストールされます.

f:id:teppay:20180923021734p:plain

これで,体重管理さんにDMを送ると,それがGASのWebサーバに通知される様になりました.
1枚目はSlackのDMで体重管理さんに送ったメッセージで,2枚目はGASでconsole.logして表示したPOSTリクエストのペイロードの一部です. f:id:teppay:20180923021743p:plain f:id:teppay:20180923021749p:plain

これでほぼ完成です.あとは受け取ったEventに対してSlack側にメッセージを送るなり,SpreadSheetに記録するなりの処理を書くだけです.
この記事は以下の2つの機能を実装してひとまず終わりにしようと思います.

  • 体重を送信すると,SpreadSheetに記録する
  • ぐらふと送信すると,グラフを返信してくれる

体重の記録機能

体重を記録してほしいというのが当初の目的ですので,この機能を実装しないわけには行きません.
この機能に必要なのは

  • 体重をメッセージでうけとって,SpreadSheetに書き込む
  • 返事をする

の2つの処理です.

体重をメッセージで受け取って,SpreadSheetに書き込む

今回のBotはなるべく簡単に体重を入力したいので,小数を入力したら体重とみなすことにします. 文字列を小数に直したものと,unix timeを引数としてうけとってSpreadSheetに書き込む関数recodeWeightは以下のようになります.

function getSpreadsheet(){
  var spreadsheetId = '<SpreadSheetID>';
  var spreadsheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('シート1');
  return spreadsheet;
}

function recodeWeight(w, utime){
  //console.log('recodeWeight');
  var spreadsheet = getSpreadsheet();
  var d = new Date(utime*1000);
  spreadsheet.appendRow([d.getFullYear(), d.getMonth()+1, d.getDate(), d.getHours(), d.getMinutes(), w])
}

下記の記事にGASからSpreadSheetにアクセスする方法がわかりやすくまとまっています. Google Apps Script で Spreadsheet にアクセスする方法まとめ

返事をする

体重を送って返事がなかったらBot感がないし,ほんとに記録できてるのかもよくわからないので返事をしてほしいので返事をする処理も実装します.
今回Eventを通知してもらうためにEvent APIを使用していますが,これだけでは返事ができません.Botからメッセージを送りたい場合Web APIというものを使用します.
詳しいことは下記のサイトに書いてあるので説明は端折ります. eventオブジェクトと文字列message返事を返すreplyDM関数は以下のようになります.

function replyDM(e, message){
  var url = 'https://slack.com/api/chat.postMessage'
  var token = '<Bot User OAuth Access Token>';
  
  var data = {
    'channel' : e.channel,
    'text' : message,
    'as_user' : true
  };
  
  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+token},
    'payload' : JSON.stringify(data)
  };
  
  var response = UrlFetchApp.fetch(url, options)
  console.log(response.getContentText());

f:id:teppay:20180923021809p:plain

ぐらふと送信すると,グラフを返信してくれる

記録してるだけではなんかつまらないのでそれをグラフにして表示してもらうことにします.
この機能に必要な処理は,

  • SpreadSheetのデータからグラフを作る
  • グラフを画像として出力する
  • 画像をSlackに送信する

の3つです

SpreadSheetのデータからグラフを作る

まずは1つ目の機能で記録したデータからグラフを作ります. Spreadsheetの機能を使ってグラフを作ることも出来るんですが,今回はChartsクラスを使ってみました.これはなんかSpreadsheetを使う場合のグラフ自体のカスタマイズ方法がわからなかったためですw
意外とこっちを使っている記事がすくなくて大変でした.
Class LineChartBuilder  |  Apps Script  |  Google Developers

function buildLineChart(){
  var sheet = getSpreadsheet();
  var dates = sheet.getRange(1, 1, sheet.getLastRow(), 3).getValues();
  var weights = sheet.getRange(1, 6, sheet.getLastRow(), 1).getValues();
  var dataTable = Charts.newDataTable()
                        .addColumn(Charts.ColumnType.DATE, 'date')
                        .addColumn(Charts.ColumnType.NUMBER, 'weight');
  
  for(var i=0; i<sheet.getLastRow(); i++){
    var date = new Date(dates[i][0], dates[i][1], dates[i][2])
    dataTable.addRow([date, weights[i][0]])
  }
  
  var chart = Charts.newLineChart()
                    .setDataTable(dataTable)
                    .setTitle('My Weight')
                    .setTitleTextStyle(Charts.newTextStyle().setFontSize(40))
                    .setDimensions(800, 600)
                    .setColors(['#4aa0f7'])
                    .setPointStyle(Charts.PointStyle.MEDIUM)
                    .setOption('vAxis.minValue', 50)
                    .setOption('vAxis.maxValue', 70)
                    .setXAxisTextStyle(Charts.newTextStyle().setFontSize(13))
                    .setXAxisTitle('Date').setXAxisTitleTextStyle(Charts.newTextStyle().setFontSize(20))
                    .setYAxisTextStyle(Charts.newTextStyle().setFontSize(13))
                    .setYAxisTitle('Weight').setYAxisTitleTextStyle(Charts.newTextStyle().setFontSize(20))
                    .build()

  return chart
}
グラフを画像として保存する グラフから画像を生成する

保存してから送る必要があるのかと思ってたら生成して,そのまま送信できるみたいです
しかも画像の生成は chart.getBlob() だけ

画像をSlackに送信する

これはSlackのAPIを使うだけです files.upload method | Slack

function uploadImage(channel, imageBlob){
  var url = 'https://slack.com/api/files.upload';
  var token = credentials['slackToken'];
  
  var data = {
    channels: channel,
    file: imageBlob,
    filetype: 'png',
    title: 'Chart',
  };
  
  var options = {
    'method': 'POST',
    'headers': {'Authorization': 'Bearer '+token},
    'payload': data
  };
  
  var response = UrlFetchApp.fetch(url, options)
}

これですべての部品が完成しました コードはGitHubに置いてあるので参考にしていただけたら喜びます

GitHub - teppay/health_manager: This is Slack Bot that manage our health

f:id:teppay:20180923021824p:plain

補足:不具合

これまでのコードをコピペしただけでは1回の「ぐらふ」に対して4回くらい画像が送られてくるという不具合が発生します.
これはおそらくdoPostの中で重たい処理(グラフ・画像の生成)を行うことでEventAPIに対するレスポンスが遅くなって,(届いてないと勘違いされることで)Slack側からのWebHookが何回も送られてきてしまうことが原因だと思っています.
GASでは非同期処理が基本的にできなそうなので,暫定的に以下の方法で乗り切っています.なにかいい方法を知っている方はぜひ教えてください!!!

Slack Event APIでは,それぞれのEventにevent_idという値が割り振られているのでそれが重複しているWebhookを捨てるという方法をとりました. CacheServiceというkey/value方式で値を保存できる仕組みが用意されているので,そこにevent_idを保存しています

function doPost(e) {
  var postData = JSON.parse(e.postData.getDataAsString());
  
  var res = {};
  if(postData.type === 'url_verification') {
    res = {'challenge':postData.challenge}
  } else if(postData.type === 'event_callback'){
    console.log(postData);
    if(!eventIdProceeded(postData.event_id)){
       eventHandler(postData.event);
    }
  }
  
  return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
}

function eventIdProceeded(eventId){
  var prevEventId = CacheService.getScriptCache().get(eventId);
  if(prevEventId){
    return true;
  }else{
    CacheService.getScriptCache().put(eventId,'proceeded', 60*5);
    return false;
  }
}

参考Webサイト