Slack × GAS × Spreadsheetで作る簡単投票アプリ

はじめまして。ベトナムでスクラムマスターをしているInoueと申します。
今回初めてSlack AppとGASを触った記念に、記事として落とさせていただきます。
## What
Slack、GAS、Spreadsheetを用いた投票アプリです。
イメージはこんな感じです。
> スプレッドシートに投票対象を用意
↓
Slackコマンドで投票開始
↓
投票内容がスプレッドシートに反映される

## Why
リモートでもこんな感じでストーリーポイントを決定できないかと
Slack Appを探しましたが、使いやすいものはほぼ有料で手が出せなかったのです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/5758fc92-d098-8e0c-1410-526309cabdda.png" width="300">
じゃあ作ればいいと。
## How
具体的な作成方法を解説します。
### 1. Slack AppとGoogle Apps Scriptを用意する

こちらの記事を参考にさせていただきました。
記事の手順通りにSlack AppとGASを作成します。

https://qiita.com/sew_sou19/items/723feb39ea4aa7becfab#%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%AB%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%ABinstall-app-to-workspace%E3%82%92%E3%82%AF%E3%83%AA%E3%83%83%E3%82%AF

### 2. スプレッドシートを用意する
新規のスプレッドシートを作成します。
今回は画像のような形。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/e042660d-cb50-f85e-59be-fbfc25c3b947.png" width="400">
A列をプロダクトバックログのID、Bに名前、Cに詳細を記入します。
A列は何でも良いですがかユニークにすること。

### 3. Google Apps Scriptを書く
#### Slackのスラッシュコマンド実行で発火される関数を書いていく

コマンド実行後、このように表示されるのを目指します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/366f757d-ae00-d718-6c04-8a2c910dede4.png" width="500">

```javascript:main.gs
function doPost(e) {
  // TODO Verification Tokenで取得するトークンを設定
  const slack_token = 'Your valification token'; 
  // 指定したチャンネルからの命令しか受け付けない
  if (slack_token != e.parameter.token) {
    throw new Error(e.parameter.token);
  }

  // エラーメッセージ送信の関数を定義
  // コマンド実行にエラーが出たらエラーを返してあげます。
  const sendErr = (text) => {
    const errMsg = {
      "attachments": [
        {
          "text": text,
          "color": "fc0b03" // メッセージの左のバーの色
        }
      ]
    };
    return ContentService.createTextOutput(JSON.stringify(errMsg)).setMimeType(ContentService.MimeType.JSON);
  };

  // Slack commandの引数が指定されていない場合
  if (!e.parameter.text) {
    return sendErr('command parameter [sheet_name] and [pb_id] are required.');
  }

  // Slackのスラッシュコマンドの引数
  const commantParams = e.parameter.text.split(' ');
  if (!commantParams[1]) {
    return sendErr('command parameter [pb_id] is required.')
  }

  const sheetName = commantParams[0];
  const pbId = commantParams[1];

  // このシートを取得
  // https://docs.google.com/spreadsheets/d/!!この部分!!/edit
  const spreadsheet = SpreadsheetApp.openById('xxxxxxxxxxxx');
  // 引数で渡されたsheet_nameをもとにシートタブを選択する
  const sheet = spreadsheet.getSheetByName(sheetName);
  // シートがなかった場合エラーを返してあげる
   if (!sheet) {
    return sendErr('Sorry we couldn\'t find the sheet.')
  }

  // 最終行を取得
  const lastRow = sheet.getLastRow();
  // セルの範囲指定
  const range = sheet.getRange(2, 1, lastRow, 3);
  // 範囲指定した説の値を配列で取得する
  const values = range.getValues();
  // Slackのスラッシュコマンドの引数で渡ってきたPb-idでソートする
  const pbs = values.filter(value => value[0] == pbId);
  // 配列で返ってくるのでindex0
  const pb = pbs[0];
  // 見つからなければエラーを返してあげる
  if (!pb) {
    return sendErr('Sorry we couldn\'t find the PB.')
  }

  // 選択肢を定義
  const fibonacciNumbers = [1,2,3,5,8,13];
  const options = [];
  fibonacciNumbers.map(number => {
    options.push({
      "text": number,
      "value": number
    })
  });

  // 選択肢部分を定義
  const actions = [{
    "name": "0", // 適当
    "text": "Select a number", // 選択肢のplaceholder
    "type": "select", // select or button ボタンは5個までしか並べない
    "options": options, // 選択肢
    "data_source": 'static',
    "confirm": { // 選択したときに確認メッセージを出してあげる
      "title": "Confirm",
      "text": "Would you like to vote for this number?",
      "ok_text": "Yes",
      "dismiss_text": "Stop, I've changed my mind"
    }
  },{ // 投票終了
    "name": "0", // 適当
    "text": "Everyone finished voting", // ボタンのテキスト
    "type": "button", // select or button
    "value": "end",
    "style" : "danger", // ボタン色 primaryもあsる
    "confirm": {
      "title": "Are you sure?",
      "text": "This operation ends everyone's vote.",
      "ok_text": "OK",
      "dismiss_text": "Cancel"
    }
  }];

  // 返答データ本体
  const data = {
    "response_type":"in_channel", // "ephemeral" or "in_channel" epemeralはコマンド実行者のみに表示される
    "replace_original" : false, // 実行したコマンドをメッセージに置き換えるか
    //アタッチメント部分
    "attachments": [{
      "title": pb[1],// アタッチメントのタイトル
      "text": pb[2],// アタッチメント内テキスト
      "fallback": "Please check in an environment that supports button display",// ボタン表示に対応してない環境での表示メッセージ. 
      "callback_id": `${sheetName}_${pbId}`,
      "color": "#00bfff", // 左のバーの色を指定する
      "attachment_type": "default",
      // 選択肢部分
      "actions": actions
    }],
  };

  // レスポンス返却
  return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON);
}
```

デプロイします。
デプロイURLをSlackのスラッシュコマンドのRequest URLに設定します。

これでスラッシュコマンドを実行後、前述した画像のようなメッセージが返ってくるはずです。
スラッシュコマンド

```
/your-slash-command sheet_name pb_id
```
引数`sheet_name`はスプレッドシートのシートタブの名前
引数`pb_id`はスプレッドシートのA列に対応しています。

メッセージ形式は
Slack公式でレスポンスの詳細を確認することでカスタマイズできるかと思います。

https://api.slack.com/legacy/interactive-message-field-guide#confirmation_fields

#### 選択肢またはボタンクリックで発火される関数を書いてく
選択肢を選ぶとスプレッドシートに反映、
赤いボタンを押すと投票を終了させる処理を書いていきます。

さっきとは別のApps scriptプロジェクトを開きます。

```javascript:main_postback.gs
function doPost(e) {
  // ペイロード部分の取り出し
  const payload = JSON.parse(e["parameter"]["payload"]);
  // 何番目のPBか
  const pbNumber = Number(payload["actions"][0]["name"]);
  // 投票された数値
  const selectedOption = payload["actions"][0]["selected_options"] && payload["actions"][0]["selected_options"][0]['value'];
  // finishボタン
  const value = payload["actions"][0]["value"];
  // 投票者のSlackユーザーネーム
  const userName = payload["user"]['name'];
  // attachmentに指定したCallbackId
  const callbackId = payload["callback_id"].split('_');
  // 対象のSheetタブの名前
  const sheetName = callbackId[0];
  // 対象のスプリントの名前
  const pbId = callbackId[1];

  // エラーメッセージ送信の関数を定義
  const sendErr = (text) => {
    const errMsg = {
      "response_type":"ephemeral",
      "attachments": [
        {
          "text": text,
          "color": "fc0b03"
        }
      ]
    };
    return ContentService.createTextOutput(JSON.stringify(errMsg)).setMimeType(ContentService.MimeType.JSON);
  };

  // シートを取得
  const spreadsheet = SpreadsheetApp.openById('同じスプシのID');
  // 引数で渡されたsheet_nameをもとにシートタブを選択する
  const sheet = spreadsheet.getSheetByName(sheetName);
  const lastColumn = sheet.getLastColumn();
  const startUserClmnNum = 4;
  
  // 投票者セルの範囲指定
  const userRange = sheet.getRange(1, startUserClmnNum, 1, lastColumn);
  // 範囲指定したセルの値を配列で取得する
  const userValues = userRange.getValues();

  // indexで投票者の列を割り出す
  let userPosition = null;
  userValues[0].map((value, index) => {
    if (value === userName) {
      userPosition = startUserClmnNum + index; 
      return;
    }
  });

  // User名シートに記入されていない場合は記入する
  if (userPosition == null) {
    const newVoterClmn = lastColumn + 1;
    sheet.getRange(1, newVoterClmn).setValue(userName);
    userPosition = newVoterClmn;
  }

  // ユーザーのセル範囲を取得
  const range = sheet.getRange(1, userPosition);
  // ユーザーの座標を取得
  const userR1c1 = range.getA1Notation();
  // 行は要らないのでトリミング
  const column = userR1c1.replace(/[0-9]|/g, '');

  // 最終行を取得
  const lastRow = sheet.getLastRow();
  // セルの範囲指定
  const pbRange = sheet.getRange(2, 1, lastRow, 3);
  // 範囲指定したcellの値を配列で取得する
  const pbValues = pbRange.getValues();
  // 対象スプリントのPBs
  const pbs = pbValues.filter(value => value[0] == pbId);
  // 投票されたPB
  const pb = pbs[pbNumber];
  // PB名で列を検索
  var textFinder = sheet.createTextFinder(pb[1]);
  var cells = textFinder.findAll();
  // 検索結果の座標を取得
  const pbR1c1 = cells[0].getA1Notation();
  // 列は要らないのでトリミング
  const row = pbR1c1.replace(/[A-Z]|/g, '');

  // PBの行、投票者の列を定義
  const votingR1c1 = column + row;
  // シートに投票を書き込む
  if (value !== "end") {
    sheet.getRange(votingR1c1).setValue(selectedOption);
  }

  // 選択肢を定義
  const fibonacciNumbers = [1,2,3,5,8,13];
  const options = [];
  fibonacciNumbers.map(number => {
    options.push({
      "text": number,
      "value": number
    })
  });

  // 選択肢部分を定義
  const actions = [{
    "name": "0", // 適当
    "text": "Select a number", // placeholder
    "type": "select", // select or button
    "options": options, // 選択肢
    "data_source": 'static',
    "confirm": {
      "title": "Confirm",
      "text": "Would you like to vote for this number?",
      "ok_text": "Yes",
      "dismiss_text": "Stop, I've changed my mind"
    }
  },{
    "name": "0", // 適当
    "text": "Everyone finished voting", // placeholder
    "type": "button", // select or button
    "value": "end",
    "style" : "danger",
    "confirm": {
      "title": "Are you sure?",
      "text": "This operation ends everyone's vote.",
      "ok_text": "OK",
      "dismiss_text": "Cancel"
    }
  }];

  // 返答データ本体
  const attachments = {
    "attachments": [{
      "title": pb[1],// アタッチメントのタイトル
      "text": (value === "end") ? "Finished voting" : pb[2],// アタッチメント内テキスト
      "fallback": "Please check in an environment that supports button display",// ボタン表示に対応してない環境での表示メッセージ. 
      "callback_id": `${sheetName}_${pbId}`,
      "color": "#00bfff", // 左の棒の色を指定する
      "attachment_type": "default",
      // 選択肢部分
      "actions": (value === "end") ? [] : actions
    }],
  };

  // レスポンス返却
  return ContentService.createTextOutput(JSON.stringify(attachments)).setMimeType(ContentService.MimeType.JSON);
}
```
デプロイします。
デプロイURLをSlackのInteractivity & ShortcutsのRequest URLに貼ったら完成です！

投票すると画像のように反映されます。
![スクリーンショット 2021-09-01 20.43.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563564/fc8a6508-9f4c-6d65-426f-5da1cd679921.png)


お疲れさまでした。