LoginSignup
30
15

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-24

はじめに

2022-10-24_12-33-04_AdobeExpress (1).gif

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

timesとは

弊社ではコミュニケーションツールとしてSlackを採用しており、times文化があります。
timesとは各々が個人のチャンネルを持ち、自由に発言していく場のことです。
分報と読んだり社内Twitterと呼んだり会社によって異なるようです。
Twitter代わりにしてる人や、勤怠システムを叩く場として使っている人、それぞれです。

参考

Slack Block Kitとは

Slack Block Kitとは、Slackが2019に公開したUIの仕組みです。Block Kit | Slack
image.png

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

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

メリット

  • JSON形式でパーツを組み合わせるため、エンジニアが直感的に扱える。
  • インタラクティブな機能のためのパーツも用意されている。
    • モーダルウィンドウなんかも存在する。
  • Block Kit UI Builderという公式ツールにおいてドラッグ・アンド・ドロップでUIが構築できる。
    • しかもBlock Kit UI Builderから自分が所属する任意のWork Space、channelに対してテスト投稿が可能。
    • テンプレートから作成も可能。

デメリット

  • 日本語資料が少なめ
  • Slack公式のドキュメントが不親切

参考

①Slack Appの準備

  1. Appを作成する。
    https://api.slack.com/apps?new_app
    ログインしていない場合はWorkSpaceにログインします。
    image.png
    Create an Appをクリック。
    image.png
    ここではFrom scratchを選択します。
    image.png
    ここでアプリの命名とワークスペースの選択をします。
    アプリの名前は日本語・英語OKで、ワークスペースはブラウザでログインしているものだけが表示されます。
    image.png

  2. 権限の付与を行う。
    OAuth & Permissions」→ScopesAdd an OAuth Scopeをクリック。
    以下の権限を付与する。(画像は権限を付与後の様子)

  • chat:write:botとしてメッセージを投稿する。
  • chat:write.public:botがメンバーになっていないチャンネルにメッセージを投稿する。
  • im:write:ダイレクトメッセージを投稿する。
  • incoming-webhook:チャンネルを限定してWeb Hook URLを利用して投稿する。
    image.png
    不要な権限も含まれているかもしれません。(変更するごとに管理者の承認が要るので面倒で減らしていない)
  1. Appの表示名を設定。
    App HomeApp Display NameEditをクリックして名前を付ける。
    ショートカットなどで利用する名前なのでどちらもアルファベットで入力する。
    image.png

  2. Appの画像設定
    Basic Information」→ 「Display Information

  • App name → Slack Appの名前
  • Short description → Slack上でのbotの説明
  • App icon & Preview → Slack上でのbotのアイコン
  • Background color → Slack上でのbotの背景
    image.png

②Google App Scriptの準備

Google SpreadSheetに問題を作る

問題登録のためにSpreadSheetを作成し、問題と選択肢、解答解説を作っていきます。
回答数・正解数は記録して随時出題分に反映するのが目的です。
image.png

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

Google App Scriptを開く

image.png

問題投稿用のコードを書く。

問題投稿用のコードは以下のとおりです。(畳み込み)

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;
}

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/の次のブロック」という意味です。

//画像URLの変換
  const img_url = "https://drive.google.com/uc?id="+qaData[0][2].match(/\/file\/d\/(.*?)\//)[1];

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

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

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');
 }

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

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

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

メッセージのアップデート

payloadreplace_original: trueをつけるとメッセージをアップデートできる。

const payload = { blocks: blockKit ,replace_original: true };

その際にはボタンのアクションでやってきたデータに含まれるresponse_urlに送り返す必要がある。

UrlFetchApp.fetch(response_url, options);

③Slack AppとGASの繋ぎ込み

セキュリティに関わる情報を登録する。

SlackAppの設定画面から、

  • Web Hook URL:「Incoming Webhooks」→「Activate Incoming Webhooks
    image.png

  • Bot User OAuth Token:「OAuth & Permissions」→「OAuth Tokens for Your Workspace
    image.png

「プロジェクトの設定」を開く。
image.png
「スクリプトプロパティ」に登録していく。
画像内SSIDはSpreadsheetのIDを入れている。
image.png

Webアプリとして公開する。

画面右上からデプロイする。
image.png
外部からアクセスさせるために「アクセスできるユーザー」を「全員」にする。
image.png
ウェブアプリURLをコピーしておく。
image.png

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

Slack側にGASのWebアプリを登録する。

ボタンクリックの反応をGASに返すために設定を行う。
Interactivity & Shortcuts」→「Interactivity」をオンにする。
先程のWebアプリURLを貼り付ける。
image.png

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

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

④定期実行の設定

毎日投稿の設定を行う。
祝日休日はプログラム側でスキップする設定にしている。
「トリガー」を開き、右下の「トリガーを追加」をクリック。
image.png
このように設定すると毎日稼働する。
実行するデプロイに関してはWebアプリのデプロイと同じものを指しているので最新のデプロイ番号を選び、関数も選択する。
image.png

社内の反応

意外とみんな間違えていた。
基本の効率を教えるクイズとしては良いチョイスをできている気がする。
image.png

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

まとめ

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

30
15
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
30
15