【GAS × Slack】社内timesに麻雀何切るクイズを出してみた話

# はじめに
![2022-10-24_12-33-04_AdobeExpress (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/34a3d8e8-851f-a0a0-a425-944e4b4ec3a4.gif)

社内のtimesチャンネルで麻雀の何切る問題を投稿し、ボタンクリックで回答すると、回答者にDM（ダイレクトメッセージ）で解答解説が届くSlack Appを作りました。
AppのバックエンドはGoogle App Scriptを採用しています。
麻雀の何切る問題とは言っていますが、要は3択クイズです。

## timesとは

<details>

弊社ではコミュニケーションツールとしてSlackを採用しており、times文化があります。
timesとは各々が個人のチャンネルを持ち、自由に発言していく場のことです。
分報と読んだり社内Twitterと呼んだり会社によって異なるようです。
Twitter代わりにしてる人や、勤怠システムを叩く場として使っている人、それぞれです。
### 参考
- [社内コミュニケーションが苦手な人のためのtimes文化入門 - Qiita](https://qiita.com/autotaker1984/items/79576581e239244772f1)
- [Slackのtimesのメリット・ デメリットについて改めて考えてみる - note](https://note.com/masashignio/n/n7f5daec0b5dc)

</details>

## Slack Block Kitとは

Slack Block Kitとは、Slackが2019に公開したUIの仕組みです。[Block Kit | Slack](https://api.slack.com/block-kit)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/b00a4c67-f893-25b5-4871-2ab9ae7b86b7.png)

<details>

> JSON形式で定義されるパーツを組み合わせることで、デバイスに最適なUIを構築することができるフレームワークです。メッセージ送信API を利用することで、ユーザーがアクション可能なメッセージをチャット上に送ることができ、ユーザーの応答 (入力パーツへの入力結果) を任意のサーバーに送信することが可能です。
引用元：[Power Automate で Slack Block Kit メッセージを送る - MoreBeerMorePower](https://mofumofupower.hatenablog.com/entry/block-kit-automate)

それ以前はAttachmentsという仕組みを使ってリッチなUIを構築できましたが、設定が煩雑で、自由度も高いとはいえません。公式App系のよくできたUIは大抵Block Kitで作られていそうな感じがします。

### メリット
- JSON形式でパーツを組み合わせるため、エンジニアが直感的に扱える。
- インタラクティブな機能のためのパーツも用意されている。
    - モーダルウィンドウなんかも存在する。
- Block Kit UI Builderという公式ツールにおいてドラッグ・アンド・ドロップでUIが構築できる。
    - しかもBlock Kit UI Builderから自分が所属する任意のWork Space、channelに対してテスト投稿が可能。
    - [テンプレート](https://api.slack.com/tools/block-kit-builder?template=1)から作成も可能。
### デメリット
- 日本語資料が少なめ
- Slack公式のドキュメントが不親切

### 参考
- [Block Kit Builder を使ってインタラクティブな Slack アプリをプロトタイピングしよう - Qiita](https://qiita.com/seratch/items/628751be65de9eb23a80)
- [【slack api (Block Kit)】×【GAS】でモーダル画面投票アプリを作る：1回目](https://zenn.dev/se_ya/articles/4258968f14752d)
</details>

# ①Slack Appの準備


1. Appを作成する。
https://api.slack.com/apps?new_app
ログインしていない場合はWorkSpaceにログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/4c6df274-de05-6212-b8c6-4bea9f3d38cf.png)
**Create an App**をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/392571fc-c60b-39e0-fa1a-abd5dcf67ac3.png)
ここでは**From scratch**を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/44ac8072-f9bb-6ded-8945-22c57ba7d965.png)
ここでアプリの命名とワークスペースの選択をします。
アプリの名前は日本語・英語OKで、ワークスペースはブラウザでログインしているものだけが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/1061196a-c6c3-d2c5-e6f8-5027bf1f1cd5.png)

2. 権限の付与を行う。
「**OAuth & Permissions**」→**Scopes**の**Add an OAuth Scope**をクリック。
以下の権限を付与する。（画像は権限を付与後の様子）
- chat:write：botとしてメッセージを投稿する。
- chat:write.public：botがメンバーになっていないチャンネルにメッセージを投稿する。
- im:write：ダイレクトメッセージを投稿する。
- incoming-webhook：チャンネルを限定してWeb Hook URLを利用して投稿する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/82710dd6-7090-0170-06a8-c3cda3dbcf88.png)
不要な権限も含まれているかもしれません。（変更するごとに管理者の承認が要るので面倒で減らしていない）

3. Appの表示名を設定。
**App Home** → **App Display Name**の**Edit**をクリックして名前を付ける。
ショートカットなどで利用する名前なのでどちらもアルファベットで入力する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/2052979f-6ede-fb72-d372-37acf1b0036f.png)

4. Appの画像設定
「**Basic Information**」→ 「**Display Information**」
- App name → Slack Appの名前
- Short description → Slack上でのbotの説明
- App icon & Preview → Slack上でのbotのアイコン
- Background color → Slack上でのbotの背景
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/5818d3d8-d5f3-f8a4-fa7f-cfdc79aeb3fc.png)

# ②Google App Scriptの準備

## Google SpreadSheetに問題を作る

問題登録のためにSpreadSheetを作成し、問題と選択肢、解答解説を作っていきます。
回答数・正解数は記録して随時出題分に反映するのが目的です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/91a2bfd1-bf15-53ce-2309-13962271bb1c.png)

牌姿は園田賢プロの「牌画作成くん BYその研」で作成しました→http://mahjong-manage.com/paiga/paiga1.php

## Google App Scriptを開く
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/45587b1c-5e37-046e-af09-aa6bf8f62e33.png)

## 問題投稿用のコードを書く。
問題投稿用のコードは以下のとおりです。（畳み込み）

<details>

```javascript:PostQuestion.gs
//プロジェクトプロパティとして設定した値を読み出す。（後述）
const WEBHOOK_URL = PropertiesService.getScriptProperties().getProperties().WEB_HOOK;

const sendVoteStartButton = () => {

  //タイマー実行するが土日祝日にはスキップさせるための処理
  var today = new Date();
  if (isWorkday(today) == false) { 
    return;
  }

  //SpreadSheetから問題文と選択肢の取得
  const SS_ID = PropertiesService.getScriptProperties().getProperties().SS_ID;//スプレッドシートID
  const ss = SpreadsheetApp.openById(SS_ID);//スプレッドシートの呼び出し
  const sheet = ss.getSheetByName("シート1");//シート呼び出し
  const lastRow = sheet.getLastRow();//最終行チェック

  //検索
  const questionDate = toLocaleString(today);//yyyy/MM/dd形式の日付
  const nameArray = sheet.getRange(1, 1, lastRow, 1).getValues().flat(); //二次元配列を一次元化
  const qaData = sheet.getRange(nameArray.indexOf(questionDate) + 1, 1, 1, 12).getValues();//対象日の行のみ抽出

  //画像URLの変換
  const img_url = "https://drive.google.com/uc?id="+qaData[0][2].match(/\/file\/d\/(.*?)\//)[1];
  
  //　ここにblock kitを定義します
  const blockKit = [
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": qaData[0][0],
        "emoji": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": qaData[0][3],
        "emoji": true
      }
    },
    {
      "type": "image",
      "title": {
        "type": "plain_text",
        "text": "No." + qaData[0][11],
        "emoji": true
      },
      "image_url": img_url,
      "alt_text": "marg"
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][4],
            "emoji": true
          }
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][5],
            "emoji": true
          }
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][6],
            "emoji": true
          }
        }
      ]
    }
  ];

  const payload = { blocks: blockKit };
  const options = { method: 'POST', payload: JSON.stringify(payload) };

  //Slackへメッセージを送信
  UrlFetchApp.fetch(WEBHOOK_URL, options);
}

//Date関数からyyyy/MM/DD表記の文字列を生成する
function toLocaleString(date) {
  return [
    date.getFullYear(),
    date.getMonth() + 1,
    date.getDate()
  ].join('/');
}

// 指定された日が営業日か（営業日 = 「土日でない」「祝日カレンダーに予定がない」）
// 営業日 = true
function isWorkday(targetDate) {

  // targetDate の曜日を確認、週末は休む (false)
  var rest_or_work = ["REST", "mon", "tue", "wed", "thu", "fri", "REST"]; // 日〜土
  if (rest_or_work[targetDate.getDay()] == "REST") {
    return false;
  };

  // 祝日カレンダーを確認する
  // Google公式の日本の祝日カレンダーを参照している
  var calJpHolidayUrl = "ja.japanese#holiday@group.v.calendar.google.com";
  var calJpHoliday = CalendarApp.getCalendarById(calJpHolidayUrl);
  if (calJpHoliday.getEventsForDay(targetDate).length != 0) {
    // その日に予定がなにか入っている = 祝祭日 = 営業日じゃない (false)
    return false;
  };

  // 全て当てはまらなければ営業日 (True)
  return true;
}

```

</details>

Web Hook URLを利用してメッセージを送信しています。

大事なポイントとして**Google Dirveの画像は共有URLそのままだとGASから画像を取得できません。**
そこで
```
https://drive.google.com/file/d/{ID}/view?usp=sharing
```
のID部分を
```
https://drive.google.com/uc?id={ID}
```
としてあげることでGASからアクセスできるようになります。
スプレッドシートにはDrive上で取得したURLをそのまま貼り付けておいてGASの方で変換をかけてあげます。正規表現で`/\/file\/d\/(.*?)\//`は「`/file/d/`の次のブロック」という意味です。
```javascript
//画像URLの変換
  const img_url = "https://drive.google.com/uc?id="+qaData[0][2].match(/\/file\/d\/(.*?)\//)[1];
```

## 回答受付用のコードを書く。

回答受付用のコードは以下のとおりです。（畳み込み）

<details>

```javascript:CheckAnswer.gs
//プロジェクトプロパティとして設定した値を読み出す。（後述）
const slack_app_token = PropertiesService.getScriptProperties().getProperties().token;

function doPost(e) {
    // ペイロード部分の取り出し
  let payload = JSON.parse(e["parameter"]["payload"]);

  //回答
  var answer = payload["actions"][0]["text"]["text"];

  //日時
  var questionDate = payload["message"]["blocks"][0]["text"]["text"];

  var member_id = payload["user"]["id"];

  //SpreadSheetから正解と解説の取得
  const SS_ID = PropertiesService.getScriptProperties().getProperties().SS_ID;//スプレッドシートID（プロジェクトプロパティ）
  const ss = SpreadsheetApp.openById(SS_ID);//スプレッドシートの呼び出し
  const sheet = ss.getSheetByName("シート1");//シート呼び出し
  const lastRow = sheet.getLastRow();

  //検索
  const nameArray = sheet.getRange(1, 1, lastRow, 1).getValues().flat(); //二次元配列を一次元化
  const qaData = sheet.getRange(nameArray.indexOf(questionDate) + 1, 1, 1, 12).getValues();//対象日の行のみ抽出

  let message = "";
  //正誤判定
  num_of_answer = qaData[0][9] + 1;
  if (answer == qaData[0][7]) {
    message = "正解！素晴らしい！"
    num_of_correct = qaData[0][10] + 1;
    sheet.getRange(nameArray.indexOf(questionDate) + 1, 10, 1, 2).setValues([[num_of_answer, num_of_correct]]);
  } else {
    message = "不正解！残念！"
    sheet.getRange(nameArray.indexOf(questionDate) + 1, 10, 1, 1).setValue(num_of_answer);
  };

  //画像URLの変換
  const img_url = "https://drive.google.com/uc?id="+qaData[0][2].match(/\/file\/d\/(.*?)\//)[1];
  const resultBlockkit = [
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": message,
        "emoji": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": "問題「" + qaData[0][3] + "」",
        "emoji": true
      }
    },
    {
      "type": "image",
      "title": {
        "type": "plain_text",
        "text": "No." + qaData[0][11],
        "emoji": true
      },
      "image_url": img_url,
      "alt_text": "marg"
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": qaData[0][8],
      }
    }];

  postDM(member_id, message, resultBlockkit);
  updateQuiz(payload["response_url"], qaData, num_of_answer, num_of_correct)
}

//メッセージを上書きする。
function updateQuiz(response_url, qaData, num_of_answer, num_of_correct) {
  //画像URLの変換
  const img_url = "https://drive.google.com/uc?id="+qaData[0][2].match(/\/file\/d\/(.*?)\//)[1];
  //正答率
  correctRate = (num_of_correct / num_of_answer * 100).toFixed();
  const blockKit = [
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": qaData[0][0],
        "emoji": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": qaData[0][3],
        "emoji": true
      }
    },
    {
      "type": "image",
      "title": {
        "type": "plain_text",
        "text": "No." + qaData[0][11],
        "emoji": true
      },
      "image_url": img_url,
      "alt_text": "marg"
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][4],
            "emoji": true
          }
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][5],
            "emoji": true
          }
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": qaData[0][6],
            "emoji": true
          }
        }
      ]
    },
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": "回答者" + num_of_answer + "名　正答率" + correctRate + "％",
        "emoji": true
      }
    }
  ];

  const payload = { blocks: blockKit ,replace_original: true };
  const options = { method: 'POST', payload: JSON.stringify(payload) };

  //Slackへメッセージを送信
  UrlFetchApp.fetch(response_url, options);
}

//botからDMを送る
function postDM(member_id, message, resultBlockkit) {

  //指定の[メンバーID]にDMを送信する
  const message_options = {
    "method": "post",
    "contentType": "application/x-www-form-urlencoded",
    "payload": {
      "token": slack_app_token,
      "channel": member_id,
      // "text": message,
      "blocks": JSON.stringify(resultBlockkit),
    }
  };

  //必要scope = chat:write
  const message_url = 'https://slack.com/api/chat.postMessage';
  var response = UrlFetchApp.fetch(message_url, message_options);
  var context = response.getContentText('UTF-8');
 }

```

</details>

### デバッグに便利なロジック

POSTデータを受け取る部分でイベントeの有無で分岐させると、毎回リクエストを受けなくてもテストデータによって試しながら開発できる。

```javascript
  if (e) { 
    // ペイロード部分の取り出し
    payload = JSON.parse(e["parameter"]["payload"]);
  } else {
    payload = {//テストデータ
      //console.log()などで実際に一度リクエストを受けとって使いましょう。
    };
  }
```

### メッセージのアップデート
`payload`に`replace_original: true`をつけるとメッセージをアップデートできる。

```javascript
const payload = { blocks: blockKit ,replace_original: true };
```
その際にはボタンのアクションでやってきたデータに含まれる`response_url`に送り返す必要がある。
```javascript
UrlFetchApp.fetch(response_url, options);
```

# ③Slack AppとGASの繋ぎ込み
## セキュリティに関わる情報を登録する。
SlackAppの設定画面から、
- Web Hook URL：「**Incoming Webhooks**」→「**Activate Incoming Webhooks**」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/9995f5c3-1acc-53e5-2f2d-834cb22cd5f5.png)

- Bot User OAuth Token：「**OAuth & Permissions**」→「**OAuth Tokens for Your Workspace**」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/4aa2f351-e039-cdfb-8b97-9edb9de2349d.png)


「プロジェクトの設定」を開く。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/5a36b933-43a2-ffe9-9d42-eda5dc9b0bf7.png)
「スクリプトプロパティ」に登録していく。
画像内SSIDはSpreadsheetのIDを入れている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/4433118a-70e7-e3d5-3b4a-78e3ae8681cb.png)

## Webアプリとして公開する。
画面右上からデプロイする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/b605cc66-3d02-30cb-b8c8-e8003bb696b5.png)
外部からアクセスさせるために「アクセスできるユーザー」を「全員」にする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/0f8e0b00-50b6-edab-8adf-9df4a21bd5de.png)
ウェブアプリURLをコピーしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/01f1080a-52ac-5fef-569f-4e04d24a48f2.png)

デプロイはこの時点でのコードをビルドしてAPIとして動作させているため、変更するたびにデプロイする必要がある。
また、毎回URLも変更されるため、こちらもリクエストする側に反映させる必要がある。

## Slack側にGASのWebアプリを登録する。
ボタンクリックの反応をGASに返すために設定を行う。
「**Interactivity & Shortcuts**」→「**Interactivity**」をオンにする。
先程のWebアプリURLを貼り付ける。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/a0cf3247-41f6-0856-358a-9d7450a8cc27.png)


## `Console.log()`などで動作を確認したい場合

GASでWebアプリとして実行した場合（doPost,doGetにリクエストが来て稼働）のログを見るにはGCPとの連携が必要になる。
（この方法でエラーを見るのが面倒なので、疎通まではできるだけテストデータでの開発がおすすめ）

- [Google Apps ScriptのログをGoogle Cloud Platformで確認する方法](https://www.hidetoshl.com/gas-gcp/)
- [【2022.8月修正】Google Apps Scriptでトリガー実行（doGet,doPost）のログを表示するにはGCPプロジェクトに紐付ける必要があるらしい](https://ryjkmr.com/google-apps-script-console-log/)

# ④定期実行の設定
毎日投稿の設定を行う。
祝日休日はプログラム側でスキップする設定にしている。
「トリガー」を開き、右下の「トリガーを追加」をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/a7e09074-c43c-6333-32b8-99b0542dbfe5.png)
このように設定すると毎日稼働する。
実行するデプロイに関してはWebアプリのデプロイと同じものを指しているので最新のデプロイ番号を選び、関数も選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/6775278e-78ad-e85e-5b00-e08de4fbe0bc.png)

# 社内の反応
意外とみんな間違えていた。
基本の効率を教えるクイズとしては良いチョイスをできている気がする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215371/737906e6-b2eb-5dbc-c56d-3bac7e41f844.png)

クイズの原題は「ウザク式麻雀学習 はじめの書」という麻雀何切る問題の著者として有名なウザク氏の初心者向け書籍から。

# まとめ

今回はSlack Appを簡単に作ってみた。
timesのコンテンツとして麻雀の何切る問題を投稿するbotを作成した。