日記帳

プログラミングのことをつぶやく日記です。

Hubotを通してSlackとスプレッドシートを連携してルーレットを作る

Hubotを通してSlackとスプレッドシートを連携する

GMOペパボ Advent Calendar 2018の1日目の記事です。

Slackにてルーレットをして人にメンションを飛ばせるBotを作成しました。完成イメージは↓↓↓

f:id:leokun0210:20181125231832g:plain

こんな感じで、スプレッドシートに前回の当選者を残しながらランダムで人を指定するシステムです。2019年が間近に迫っているのに今更感が否めませんが、お付き合いください。

f:id:leokun0210:20181201170601p:plain

この記事の対象者

目的

Slackとスプレッドシートを連携しようと思ったきっかけは、所属しているチームにて毎週行われるチームミーティングでは、KPTを用いた「ふりかえり」を行っていて、ファシリテーターを決める必要があります。ファシリテーターは毎回違う人を選びます。過去3、4回ぐらい前にファシリテーターになった人までは、ファシリテーターの候補から外していました。しかし、3、4週前のこととなると忘れていることが多く、覚えていない時もあります。したがって、過去3回までにファシリテーターを担当した人以外をランダムに選ぶようなルーレットを作りたいと思いました。チームミーティングの出席者が様々な事情で変動することがあり、エンジニア以外にも人数の編集しやすく、使いやすいスプレッドシートという媒体を使用して、Slackを通じてランダムでファシリテーターを指名したいと思いました。 この記事を書いた目的は、Hubotとスプレッドシートの連携の記事の多くは、完成したコードがバーンと公開されていて、仕組みを追いながら理解することができないからです。ただコピペして運用するぶんには問題ありませんが、一応エンジニアなので仕組みを理解しつつやっていきたいという気持ちがあります。ですので、初めから順を追って作成します。

要件

  • SlackのBotに対して、 ルーレット とメンションしたら、あらかじめ指定した候補者の中から1名をランダムで選択して、その人に対してメンションする。
  • 過去3回までのルーレットの当選者は、選ばれる候補者の中から外れる。
  • 候補者のリストは、スプレッドシートで管理する。

この3つの要件を満たすように作っていきましよう。

開発順序

  1. SlackとHubotを連携させる。
  2. Hubotで、Slackからのリクエストを処理できるようにする。
  3. スプレッドシートから、Hubotに対して、候補者のリストから1名をランダムに選択して、レスポンスしてあげる。
  4. HubotをHerokuへデプロイする

Hubot編

Slack側の設定

Slack App ディレクトリでHubotと検索して、アプリをワークスペースに追加します。

アプリを Slack に追加する | アプリおよびインテグレーション | Slack App ディレクトリ

「設定を追加」を選択して、Hubotの名前を入力してHubotインテグレーションを使いします。ここでは名前を「roulette」とします。

f:id:leokun0210:20181201185423p:plain

次に追加したHubotのインテグレーションの設定を変更します。APIトークンはHubotのプロセスを立ち上げるときに必要です。

f:id:leokun0210:20181204114334p:plain

Hubtoの設定

Hubotのチュートリアルの通りやっていきましょう。

Getting Started With Hubot | HUBOT

まずHubotを動かすためのパッケージをインストールします。ここでは npm については触れません。必要なパッケージは yogenerator-hubot です。

$ npm install -g yo generator-hubot

次にHubotの雛形を作成します。このときメールアドレスやHubotの名前も要求されますが、全てEnterでも構いません。このタイミングでリポジトリを作りたい人は git init をするのを忘れずに。

$ mkdir hubot-roulette
$ cd hubot-roulette
$ yo hubot

bin/hubot Hubotのプロセスを立ち上げてローカルでHubotが動くことを確認します。 hubot-roulette ping でHubotから PONG とレスポンスがあれば疎通確認は完了です。

$ bin/hubot
# 色々メッセージが出るけど動作するので無視する
hubot-roulette> hubot-roulette ping
hubot-roulette> PONG

次はHubotで簡単なスクリプトを書いて、Hello,Worldしてみます。 scripts/hello.coffee を作成します。JavaScriptでも可能ですが、今回はCoffeeScriptで書いてみます。これで、 hello と呼びかければ world と返ってきます。

$ touch scripts/hello.coffee
module.exports = (robot) ->
  robot.respond /hello/i, (msg) ->
    msg.send 'world'
$ bin/hubot
hubot-roulette> hubot-roulette hello
hubot-roulette> world

次にSlackとの連携をやってしまいます。 hubot-slack というSlackとHubotのアダプターのパッケージがあるのでインストールします。そして環境変数 HUBOT_SLACK_TOKEN にSlackの設定時に取得したAPIトークンを代入します。プロセスが起動したら、 roulette アカウントに対してDMを送信します。まずは ping を送信して疎通を確認します。次に hello と送信して world が返ってくるか確認します。

$ npm install hubot-slack
# Slackのプロセスが起動
$ HUBOT_SLACK_TOKEN="Slackで取得したAPIトークン" ./bin/hubot --adapter slack

f:id:leokun0210:20181201201345p:plain
Hubotとの会話

APIとの通信

スプレッドシートは、WebAPIを作成する機能があり、Hubotとのやり取りはhttpリクエストを通じて行います。まずは実際にスプレッドシートAPIを作成するより、スタブサーバーを立ててHubot側の動作を確認することを優先しましょう。npmでjsonから簡単にスタブサーバーを立てることのできるパッケージ json-server をインストールします。

$ npm install -g json-server
$ touch user.json

user.jsonの中身は以下の通りです。 GET "localhost:3000/users" のエンドポイントを作り、 "a" という文字列が返ってくることを期待します。

{
  "users": [
    "a"
  ]
}
# スタブサーバーを立ち上げる
$ json-server  --watch user.json

  \{^_^}/ hi!

  Loading user.json
  Done

  Resources
  http://localhost:3000/users

  Home
  http://localhost:3000

  Type s + enter at any time to create a snapshot of the database
  Watching...
# 期待通り"a"が返ってきた
$ curl -X GET "http://localhost:3000/users"
[
  "a"
]%

Hubotのスクリプトから、このスタブサーバーのエンドポイントへリクエストします。JavaSriptのHTTPクライアントを使用します。

CoffeeScript Cookbook » Basic HTTP Client

http = require 'http'

module.exports = (robot) ->
  robot.respond /hello/i, (msg) ->
    # port番号3000を指定するのを忘れずに
    http.get {host: 'localhost', port: 3000, path: '/users'}, (res) ->
      if res.statusCode is 200
        body = ''
        res.setEncoding 'utf8'
        res.on 'data', (chunk) ->
          # レスポンスのデータをbodyにまとめる
          body += chunk
        res.on 'end', ->
          # レスポンスのJSON形式のデータをパースする
          obj = JSON.parse(body)
          msg.send obj
      else
        console.log "error: #{res.statusCode}"
# きちんと"a"が取得できており、GETリクエストが成功したことがわかる
$ bin/hubot
hubot-roulette> hubot-roulette hello
hubot-roulette> a

GoogleAppsScript編

次はスプレッドシートAPIを作ります。新規のスプレッドシートを作成しましょう。 ツール>スクリプトエディタ でエディタを起動します。まずやることはスタブサーバーと同じで、単純なレスポンスを返せるようにすることです。 doGet 関数を定義して単純なJSONが返せるようにしましょう。

// GETリクエストに対する処理
function doGet(e) {
  // API形式で出力できるように準備をする
  var output = ContentService.createTextOutput();
  // JSONで出力する
  output.setMimeType(ContentService.MimeType.JSON);
  // JSON 文字列に変換
  payload = JSON.stringify({"user": "a"});
  // ContentServiceインスタンスに出力するJSONをセットする
  output.setContent(payload);
  return output;
}

このあとAPIとして公開するようにします。 公開>ウェブアプリケーションとして導入 を選択して実行ユーザーを 全員(匿名ユーザーを含む) にします。URLを取得できるので curl にて確認します。リダイレクトに追従できるように -L を付けます。

$ curl -L https://script.google.com/macros/s/[APIのID]/exec
["a"]% 

続いてはスプレッドシートから値をとってみます。A1にbと入力して、この値を返すAPIを作ります。 getValue 関数を新たに作成して、その中でA1の値を取得します。

f:id:leokun0210:20181201211634p:plain
A1にbと入力してね

// GETリクエストに対する処理
function doGet(e) {
  // API形式で出力できるように準備をする
  var output = ContentService.createTextOutput();
  // JSONで出力する
  output.setMimeType(ContentService.MimeType.JSON);
  var value = getValue();
  // JSON 文字列に変換
  payload = JSON.stringify({"user": value});
  // ContentServiceインスタンスに出力するJSONをセットする
  output.setContent(payload);
  return output;
}

function getValue() {
  // スプレッドシートID
  var id = 'シートのIDを入れる';
  var ss = SpreadsheetApp.openById(id);
  // シート名
  var sheet = ss.getSheetByName("シート1");
  // セルの値を取得する
  // sheet.getRange({行番号},{列番号})でセルの範囲を指定して、.getValueで値を取得する
  // この場合はA1を取得する
  var value = sheet.getRange(1, 1).getValue();
  return value;
}
# A1セルに入れていたものが返ってきた
$ curl -L https://script.google.com/macros/s/[APIのID]/exec
{"user":"b"}% 

ここまでくればあと一息です。あとは書き込む方法さえ知ってしまえば、冒頭のルーレットを作る上で必要なことはだいたい網羅できます。 getValue 関数の中に以下のコードを追加します。そうすると実行時の時刻がB1のセルに書き込まれます。

  // 実行した時刻をB1に書き込む
  sheet.getRange(1, 2).setValue(new Date());

f:id:leokun0210:20181201213538p:plain

続いてルーレットを作っていきます。ルーレットは、A2にカンマ区切りで候補者の名前を入れます。これはSlackのメンションするときに使うので、Slackのアカウント名です。A3からA5までは、それぞれ1~3回前のルーレットで当選した人が記録されています。この人たちは、集中して当選しないようにルーレットの抽選候補者から除外します。文字数が多くなってしまうので、個々の解説はできませんが、今までやってことを全て合わせた集合がルーレットという機能です。

f:id:leokun0210:20181201214425p:plain

// GETリクエストに対する処理
function doGet(e) {
  // API形式で出力できるように準備をする
  var output = ContentService.createTextOutput();
  // JSONで出力する
  output.setMimeType(ContentService.MimeType.JSON);
  
  // スプレッドシートID
  var id = 'シートのIDを入れる';
  var ss = SpreadsheetApp.openById(id);
  // シート名
  var sheet = ss.getSheetByName("シート1");
  // ルーレットの候補になるセルの値を取得する
  var values = sheet.getRange(2, 1).getValues();
  var resArray = values.toString().split(",");
  // 過去3回まで選ばれたものは、再度選ばれないように候補から外す
  var exclude = [];
  
  for (i = 0; i < 3; i++) {
    var x = i + 2;
    if (sheet.getRange(2, x).getValue() != '') {
      exclude[i] = sheet.getRange(2, x).getValue();
    }
  }

  if (exclude.length < resArray.length) {
    exclude.forEach(function(element) {
      resArray = arrayDelete(resArray, element);
    });
  }
  
  var target = resArray[Math.floor(Math.random() * resArray.length)];
  sheet.getRange(2, 2).setValue(target);
  if (exclude[0] !== undefined) {
    sheet.getRange(2, 3).setValue(exclude[0]);
  }
  if (exclude[1] !== undefined) {
    sheet.getRange(2, 4).setValue(exclude[1]);
  }
  
  var res = {};
  res['user'] = target;
  Logger.log(res);
  Logger.log(target);
  payload = JSON.stringify(res);
  output.setContent(payload);
  return output;
}

// valueの要素と一致したものを削除する
function arrayDelete(array, value) {
    for(i = 0; i < array.length; i++){
    if(array[i] === value){
      //spliceメソッドで要素を削除
      array.splice(i, 1);
    }
  }
  return array;
}
# 予想した返しができている
$ curl -L https://script.google.com/macros/s/[APIのID]/exec
{"user":"山根"}% 

f:id:leokun0210:20181201215110p:plain
B2に今回の当選者が入っている

ここまでできたら、実際にHubotのスクリプトでこのAPIを実行してみましょう。スプレッドシートで作成したAPIのURLは、リダイレクトしてGoogleAppsScriptを実行します。なのでリダイレクトしても追従できるように follow-redirects というパッケージをインストールします。package.jsonに追加するので -g オプションは付けません。 scripts/roulette.coffeeスクリプトを書きます。

$ npm install follow-redirects
$ touch scripts/roulette.coffee

スプレッドシートAPIのURLは漏れないに越したことはないので、環境変数から取得できるようにします。 API_URL という名前にします。

http = require 'follow-redirects'
# スプレッドシートはhttpsなので注意する
http = http.https

# 環境変数を取得
url = process.env.API_URL

module.exports = (robot) ->
  robot.respond /ルーレット/i, (msg) ->
    http.get
      host: 'script.google.com'
      path: url
    , (res) ->
      if res.statusCode is 200
        body = ''
        res.setEncoding 'utf8'
        res.on 'data', (chunk) ->
          body += chunk
        res.on 'end', ->
          obj = JSON.parse(body)
          # APIからのレスポンスを@を先頭に付けてSlackへ返す
          # レスポンスの例 {"user":"山根"}
          msg.send '@' + obj.user.toString()
      else
        console.log "error: #{res.statusCode}"
$ HUBOT_SLACK_TOKEN="Slackで取得したAPIトークン" API_URL=/macros/s/[APIのID]/exec ./bin/hubot --adapter slack

これでSlackのrouletteに「ルーレット」と呟いてみます。するときちんと抽選結果が返ってきています。

f:id:leokun0210:20181201220547p:plain
やったー🎉

このHubotをチャンネルに招待するために、 /invite @roulette を実行します。そして @roulette ルーレット とメンションを飛ばすと先ほどと同じ結果が得られます。

f:id:leokun0210:20181201220844p:plain

実運用

PCを常時起動している人だったら、ずっとHubotプロセスを実行して入ればいいのですが、そうはいかないのでHubotをHerokuへデプロイします。デプロイする前に、Hubotの雛形を作ったときのpackage.jsonのnode.jsのバージョン指定が 0.10.x になっているので適当なものに修正します。

  "engines": {
    "node": "10.10.0"
  }

Herokuへデプロイするための準備も含めて行います。

$ heroku login
$ heroku create [your-bot-name]
# Hubotにはredisが必要
$ heroku addons:create redistogo:nano
$ heroku config:set HUBOT_SLACK_TOKEN=[your-slack-token] --app [your-bot-name]
$ heroku config:set API_URL=[your-api_url] --app [your-bot-name]
$ heroku git:remote -a [your-app-name]
$ git push heroku master

これでHerokuでHubotが動きます。

実際に運用してみての感想

まだ1回しか運用していませんが、誰が前回のファシレーテータかを思い出す必要がなく、ファシリテーター決めも30秒から1秒くらいになったので煩わしさが解消されたかなーと思っています。ここまで書くのに疲れたのでもう休みます。

参考記事

hubotスクリプトの書き方とサンプル集 | mitc

Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法 - Qiita

JSON.stringify() - JavaScript | MDN

GASでGoogleスプレッドシートのセルの値、行数や列数を取得したり、セルに値を入力したりする基本 (1/2):Excel VBAプログラマーのためのGoogle Apps Script入門(2) - @IT

herokuでhubot立ててみたらカンタンだった - Qiita

HubotをHerokuでSlackに繋げるまで - Qiita

配列からランダムに値をとりだす。 - Qiita

CoffeeScript Cookbook » Basic HTTP Client

JSON Server使いこなし - モックサーバーの起動とリソース処理 | CodeGrid

typicode/json-server: Get a full fake REST API with zero coding in less than 30 seconds (seriously)