GAS + スプレッドシート + Slackで当番決めに使えるガチャを作ってみた

GASことGoogle Apps Scriptは、手軽にbotや他のGoogleサービスと連携したツールを作れるから、ちょっと楽しいですよね。
今回はスプレッドシートと組み合わせて、Slackから呼び出して使えるガチャを作ってみました。

※この記事はQiitaからの転載です。

作ろうと思った背景 #

自分が所属している事業部では、毎月月末にとある当番を決めるのにSlackBotを使ってガチャみたいにやっていました。

ただ、これだと設定された文言のいずれかをランダムで返しているだけなので、先月に担当した人がまた抽選されたりといった問題がありました。

そこで当番履歴をスプレッドシートに記録しておいて、抽選のたびに当番履歴のメンバーを除外して、抽選するようにすればいいんじゃないか?と思い、思い切って作ってみました。

※2020/1/27追記
当月の当番抽選がすでに行われており、当月の当番履歴がすでにある場合は、新たに抽選を行わずにその履歴情報から当番を通知するように改修を行いました。

構成図 #

当番ガチャの構成図

全体の処理のおおまかな流れとしてはこんな感じです。

下部でも書いているのですが、今回は旧方式のWebhookを使用しているので注意です。

作り方 #

Incoming Webhookの用意 #

まずはSlackに投稿する部分を担う、Incoming Webhookを用意します。
(Slackのワークスペースはすでにあるものとして、進めていきます)

  1. Slackワークスペースのワークスペース名のところからメニューを開く
  2. App管理画面へ
    • ※ワークスペースのオーナーの場合
      その他管理項目 → App管理
    • ※オーナーでない場合
      Slackをカスタマイズ → 左上のメニューを開く → App管理
  3. Incoming Webhook設定の追加画面へ
    • ※Incoming Webhookをまだ導入していない場合
      「Appディレクトリを検索」のところにIncoming Webhookで検索し選択 → Slackに追加
    • ※すでにIncoming Webhookを導入済みの場合
      カスタムインテグレーション → Incoming Webhook → Slackに追加
  4. チャンネルへの投稿で、投稿したいチャンネルを選択し、Incoming Webhookインテグレーションの追加を選択
  5. インテグレーションの設定
    • Webhook URLを控えておく(GAS側で使います)
    • 名前やアイコンを必要に応じてカスタマイズ
  6. 設定を保存を選択

スプレッドシートの用意 #

スプレッドシートを作成、以下のようなシートを用意します。ここではシートの名称を当番履歴表としてください。
用意するスプレッドシートの内容

メンバー表 #

ガチャ対象は〇か×で選択できるようにしておき、メンバーがガチャに参加するかどうかをここで制御できるようにします。
ガチャ対象列のデータ範囲を選択 → データ → データの入力規則
以下のように設定しておきます。
メンバー表のデータの入力規則設定

Slack member_idはSlackにガチャ結果を投稿する際に、メンションをつけるために使用します。
こちらはSlack上で確認できます。
チャンネルのメンバーリストなどから、メンバーを選択し簡易プロフィールを表示。
アイコン画像を選択してプロフィールを表示し、三点リーダから確認。
Slackアカウントのプロフィール画面

当番履歴表 #

履歴がある場合は記述しておきます。
ない場合は、表のヘッダー部分のみ記述で問題ありません。

GASの用意 #

スプレッドシート上で ツール → スクリプトエディタ からGASエディタへ行けます。
以下のコードを用意します(なお、現時点でGASではES6記法は使えません

※2020/2/16 追記
v8ランタイムに対応したため、letやアロー関数などが新たに使用できるようになりました。
これに伴いリファクタリングを行っています。

ランタイムの変更は、GASエディタを開いたときに表示される案内(Enable new Apps Script runtime powered by Chrome V8 for this project.)から変更するか、実行→Chrome V8を搭載した新しいApps Scriptランタイムを有効にする からできます。

// スプレッドシートのデータを元に当番ガチャを行い、結果をSlackに投稿する
function dutyGacha() {
  // メンバー表のデータ部分のセル範囲
  const memberDataCellRange = 'A4:C23';
  // 当番履歴表の開始列
  const historyDataColumnRangeStart = 'E';
  // 当番履歴表の終了列
  const historyDataColumnRangeEnd = 'G';
  // 当番履歴表の列範囲
  const historyDataColumnRange = historyDataColumnRangeStart + ':' + historyDataColumnRangeEnd;
  // ガチャから除外する履歴件数
  const historyDataTargetNum = 5;
  // 抽選する当番の人数
  const dutyMemberNum = 2;
  let msg;
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('当番履歴表');
    const filterHistoryData = getFilterHistoryData(sheet, historyDataColumnRange);
    const lastIndex = filterHistoryData.length - 1;
    const thisMonthMemberNameList = getThisMonthMemberNameList(filterHistoryData, lastIndex, dutyMemberNum);
    // 当月の当番履歴情報がすでにある場合はそのメンバーの情報を取得
    if (thisMonthMemberNameList !== null) {
      const thisMonthMemberList = getThisMonthMemberList(sheet, memberDataCellRange, thisMonthMemberNameList, dutyMemberNum);
      msg = createNoticeMsg(thisMonthMemberList, false);
    // ない場合は抽選に必要な情報を取得し、抽選を行い、スプレッドシートに書き込み
    } else {
      const joinMemberList = getJoinMemberList(sheet, memberDataCellRange, dutyMemberNum);
      const historyMenberList = getHistoryMemberList(filterHistoryData, lastIndex, historyDataTargetNum, dutyMemberNum);
      const gachaMemberList = getGachaMemberList(joinMemberList, historyMenberList, dutyMemberNum);
      const dutyMemberList = getDutyMemberList(gachaMemberList, dutyMemberNum);
      msg = createNoticeMsg(dutyMemberList, true);

      const insertRow = sheet.getRange(historyDataColumnRangeStart + sheet.getMaxRows()).getNextDataCell(SpreadsheetApp.Direction.UP).getRow() + 1;
      const insertRange = historyDataColumnRangeStart + insertRow + ':' + historyDataColumnRangeEnd + insertRow;
      const insertDataArr = createInsertData(dutyMemberList);
      // スプレッドシートに当月当番データを書き込み
      sheet.getRange(insertRange).setValues(insertDataArr);
      // スプレッドシートに書き込んだ行に罫線を引く
      sheet.getRange(insertRange).setBorder(false, true, true, true, true, false);
    }
  } catch (e) {
    msg = 'エラーが発生しました:' + '\nstack:\n' + e.stack;
    Logger.log(msg);
  }

  try {
    const WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty('WEBHOOK_URL');
    const jsonData =
        {
          'text': msg
        };
    const payload = JSON.stringify(jsonData);
    const options =
        {
          'method': 'post',
          'contentType': 'application/json',
          'payload': payload
        };
    UrlFetchApp.fetch(WEBHOOK_URL, options);
  } catch (e) {
    Logger.log('送信エラー:' + '\nstack:\n' + e.stack);
  }
}

// 当番履歴表の列のデータから空白セルを除いたものを返す
function getFilterHistoryData(sheet, historyDataColumnRange) {
  const range = sheet.getRange(historyDataColumnRange);
  const historyData = range.getValues();
  return historyData.filter(data => data[0] !== '');
}

// 当番履歴表に当月当番の情報があればそのメンバー名を、なければnullを返す
function getThisMonthMemberNameList(filterHistoryData, lastIndex, dutyMemberNum) {
  if (Utilities.formatDate(filterHistoryData[lastIndex][0], 'JST', 'yyyy/MM')
                           === Utilities.formatDate(new Date(), 'JST', 'yyyy/MM')) {
    const historyDataTargetNum = 1;
    return getHistoryMemberList(filterHistoryData, lastIndex, historyDataTargetNum, dutyMemberNum);
  } else {
    return null;
  }
}

// メンバー表から当月当番メンバーのslack_idと名前を抽出して、オブジェクトの配列で返す
function getThisMonthMemberList(sheet, memberDataCellRange, thisMonthMemberNameList, dutyMemberNum) {
  const memberData = sheet.getRange(memberDataCellRange).getValues();
  let thisMonthMemberList = [];
  memberData.map(data => {
    if (thisMonthMemberNameList.includes(data[2])) {
      thisMonthMemberList.push({id: data[1], name: data[2]});
    }
  });
  if (thisMonthMemberList.length < dutyMemberNum) {
    throw new Error('当月当番メンバーの情報がメンバー表に不足しています。');
  }
  return thisMonthMemberList;
}

// メンバー表からガチャ参加メンバーのslack_idと名前を抽出して、オブジェクトの配列で返す
function getJoinMemberList(sheet, memberDataCellRange, dutyMemberNum) {
  const memberData = sheet.getRange(memberDataCellRange).getValues();
  let joinMemberList = [];
  memberData.map(data => {
    if (data[0] === '') {
      joinMemberList.push({id: data[1], name: data[2]});
    }
  });
  if (joinMemberList.length < dutyMemberNum) {
    throw new Error('ガチャ参加メンバーが抽選する当番の人数より少ないです。ガチャ参加メンバーを増やしてください。');
  }
  return joinMemberList;
}

// 当番履歴表から指定件数分の履歴メンバーを抽出して配列で返す
function getHistoryMemberList(filterHistoryData, lastIndex, historyDataTargetNum, dutyMemberNum) {
  let historyMenberList = [];
  for (let i = 0; i < historyDataTargetNum; i++) {
    for (let l = 1; l <= dutyMemberNum; l++) {
      if (filterHistoryData[lastIndex - i][l] === '') {
        continue;
      }
      if (filterHistoryData[lastIndex - i][l].match(/当番.*/)) {
        return historyMenberList;
      }
      historyMenberList.push(filterHistoryData[lastIndex - i][l]);
    }
  }
  return historyMenberList;
}

// ガチャ参加メンバーから履歴メンバーを除外したものを配列で返す
function getGachaMemberList(joinMemberList, historyMenberList, dutyMemberNum) {
  const gachaMemberList = joinMemberList.filter(data => historyMenberList.indexOf(data.name) === -1);
  if (gachaMemberList.length < dutyMemberNum) {
    throw new Error('ガチャ参加メンバーから履歴メンバーを除外した数が、抽選する当番の人数より少ないです。ガチャ参加メンバーを増やしてください。'); 
  }
  return gachaMemberList;
}

// 抽選する当番人数分、当番抽選を行い、当月の当番メンバーの配列を返す
function getDutyMemberList(gachaMenberList, dutyMemberNum) {
  let dutyMemberList = [];
  let index;
  for (let i = 0; i < dutyMemberNum; i++) {
    do {
      index = random(gachaMenberList.length);
    } while (dutyMemberList.includes(gachaMenberList[index]));
    dutyMemberList[i] = gachaMenberList[index];
  }

  return dutyMemberList;
}

// 0~(length-1)の乱数を返す
function random(length) {
  //例 5人の場合 0~0.9999… * 5 の小数点切り捨てで、0~4になる
  return Math.floor(Math.random() * length);
}

// Slack通知メッセージを作成して返す
function createNoticeMsg(noticeMemberList, gachaFlg) {
  let noticeMsg = '今月の当番\n';
  // 新たにガチャを行った場合の通知メッセージ
  if (gachaFlg) {
    noticeMemberList.forEach(memberData => {
      noticeMsg += ':penguin:<デレレレレレデデン!! <' + memberData.id + '> さん\n';
    });
  // 新たにガチャを行わなかった場合の通知メッセージ
  } else {
    noticeMemberList.forEach(memberData => {
      noticeMsg += ':penguin:<デデン!! <' + memberData.id + '> さん\n';
    });
    noticeMsg += '(すでに今月ガチャ済み)\n';
  }
  return noticeMsg;
}

// スプレッドシートに記録する当月データの二次元配列を返す
function createInsertData(dutyMemberList) {
  let insertData = [Utilities.formatDate(new Date(), 'JST', 'yyyy/MM')];
  dutyMemberList.forEach(memberData => insertData.push(memberData.name));
  const insertDataArr = [insertData];
  return insertDataArr;
}

大まかな流れとしては以下のような感じです。

※シートデータの当番履歴表に当月の当番情報がすでにある場合
スプレッドシートの当番履歴表シートのデータをまるごと取得

当番履歴表に当月の当番情報があるかチェック

シートデータのメンバー表から当月の当番メンバー情報のみ取得

Slack投稿メッセージの作成

投稿

※シートデータの当番履歴表に当月の当番情報がなく、新たに抽選を行う場合
スプレッドシートの当番履歴表シートのデータをまるごと取得

当番履歴表に当月の当番情報があるかチェック

シートデータのメンバー表からガチャ対象が〇になっているメンバー情報のみ取得

シートデータの当番履歴表から指定した件数分、履歴データを取得

取得したメンバー情報から、履歴にあるメンバー情報を除外

残りのメンバーで、指定当番人数分ガチャ抽選を行う

Slack投稿メッセージの作成

ガチャ抽選結果をシートの当番履歴表に記述(+罫線をひく)

投稿

変数 #

dutyGacha関数の最初で初期化している変数については、スプレッドシートからデータを取得するにあたってのデータ範囲の指定であったり、ガチャの機能を制御したりするものです。

特にデータ範囲に関しては、スプレッドシートの状態とあっていないと予期しない動作を起こすので注意してください。

  // メンバー表のデータ部分のセル範囲
  const memberDataCellRange = 'A4:C23';
  // 当番履歴表の開始列
  const historyDataColumnRangeStart = 'E';
  // 当番履歴表の終了列
  const historyDataColumnRangeEnd = 'G';
  // 当番履歴表の列範囲
  const historyDataColumnRange = historyDataColumnRangeStart + ':' + historyDataColumnRangeEnd;
  // ガチャから除外する履歴件数
  const historyDataTargetNum = 5;
  // 抽選する当番の人数
  const dutyMemberNum = 2;

プロパティストア #

GASにはプロパティストアという機能があります。
これは、プロジェクトやドキュメントに紐付けて、キーと値形式でデータを格納できる機能で、コードにべた書きしたくない情報などを格納しておくのに向いています。いわば環境変数を別ファイルで管理するような感じです。
プロパティストアには3種類あるのですが、今回はその中でプロジェクトに紐づくデータを管理できる、スクリプトプロパティを使用しています。

設定の仕方
GASエディタ上で ファイル → プロジェクトのプロパティ を選択。
スクリプトのプロパティ タブで 行を追加 から。
今回の場合は、キーをWEBHOOK_URL、値を(先ほど控えたIncomming WebhookのURL)を追加します

※注意点
スクリプトプロパティの編集は、そのGASコードおよび連携しているGoogleサービスのオーナーのアカウントでないとできません。
なので、複数人でGASのコードを管理する場合は、スクリプトプロパティを使わずにコードに直接記述した方がいいかもしれません。

スクリプトプロパティを取得するには以下のようにして、キーを指定します。

const webHookUrl = PropertiesService.getScriptProperties().getProperty('WEBHOOK_URL');

投稿メッセージ #

Imcoming Webhook URLに送信する際に、以下のようにパラメータとして渡せば、それが投稿メッセージになります。(msg変数に投稿メッセージが入っています)

// Slack通知メッセージを作成して返す
function createNoticeMsg(noticeMemberList, gachaFlg) {
  let noticeMsg = '今月の当番\n';
  // 新たにガチャを行った場合の通知メッセージ
  if (gachaFlg) {
    noticeMemberList.forEach(function(memberData) {
      noticeMsg += ':penguin:<デレレレレレデデン!! <' + memberData.id + '> さん\n';
    });
  // 新たにガチャを行わなかった場合の通知メッセージ
  } else {
    noticeMemberList.forEach(function(memberData) {
      noticeMsg += ':penguin:<デデン!! <' + memberData.id + '> さん\n';
    });
    noticeMsg += '(すでに今月ガチャ済み)\n';
  }
  return noticeMsg;
}

投稿メッセージに関して、絵文字も使用できます。:penguin:のようにnameで指定すればOKです。
また、個人へのメンションについては<member_id>の形式で指定すると、自動的に@name形式に変換してくれます。
全体メンションの場合は<!here><!channel>で、それぞれ@here@channelになります。

// Slack通知メッセージを作成して返す
function createNoticeMsg(gachaMenber, dutyMemberList) {
  let noticeMsg = '';
  dutyMemberList.forEach(function(memberData) {
    noticeMsg += ':penguin:<デレレレレレデデン!! <' + memberData.id + '> さん\n';
  });
  return noticeMsg;
}

Slack投稿のテスト #

ここまできたら、ガチャを行って、その内容をSlackに投稿できるようになります。
GASエディタ上から実行してみましょう。
GASエディタ上で 実行 → 関数を実行 → dutyGacha を選択。
初回実行時はアクセス権の許可がいるので、確認して許可してください。
アクセス権の許可確認モーダル1

アクセス権の許可確認モーダル2

なお、案内にある通り、後でこのアクセス権を確認・削除は以下から行えます。
Googleアカウント

当番履歴表に当月の当番情報がなく、新たに抽選した場合、
以下のようにメッセージが投稿されていて、スプレッドシートの当番履歴表に履歴が追加されていたらOKです。
上がmember_idに該当するアカウントがある場合。
下がない場合で、member_idがそのまま出力されます。
Slack投稿結果(新たにガチャを行った場合)

当番履歴表に当月の当番情報がすでにある場合は、その当月の当番がSlackに投稿されていればOKです。こちらは新たにスプレッドシートに書き込みは行いません。

Slack投稿結果(新たにガチャを行わなかった場合)

うまく投稿できない場合は、ここまでの設定を見直してみてください。
また、以下の場合はエラーになるようにしています。

  • すでに履歴がある当月の当番メンバーの情報がメンバー表に存在しない
  • ガチャ参加メンバーが抽選する当番の人数より少ない
  • ガチャ参加メンバーから履歴メンバーを除外した数が、抽選する当番の人数より少ない

あとは、当番履歴表の下に関係ないデータを入れると、当番履歴表の最終行を取得する時におかしくなります。

ちなみにhistoryDataTargetNum変数の値(ガチャから除外する履歴件数)より、当番履歴表のデータ件数が少ない場合でもエラーにはならず、存在する履歴データで処理します。

Outgoing Webhookとの連携 #

今度は、このガチャをSlackの指定のチャンネルから実行できるようにしていきます。

Slack側 #

  1. Slackワークスペースのワークスペース名のところからメニューを開く
  2. App管理画面へ
    • ※ワークスペースのオーナーの場合
      その他管理項目 → App管理
    • ※オーナーでない場合
      Slackをカスタマイズ → 左上のメニューを開く → App管理
  3. Outgoing Webhook設定の追加画面へ
    • ※Outgoing Webhookをまだ導入していない場合
      「Appディレクトリを検索」のところにOutgoing Webhookで検索し選択 → Slackに追加
    • ※すでにOutgoing Webhookを導入済みの場合
      カスタムインテグレーション → Outgoing Webhook → Slackに追加
  4. ガチャを呼び出せるようにするチャンネルを選択し、Outgoing Webhookインテグレーションの追加を選択
  5. インテグレーションの設定
    • トークンを控えておく(GAS側で使います)

この段階ではまだ設定を保存としないで、そのまま設定画面のままにしておきます。

GAS側 #

1.スクリプトプロパティに先ほど控えたトークンを登録。(ここではキーをSLACK_OUTGOING_TOKENとしています)
2.以下のコードをdutyGacha関数の上に追加します。

// POSTリクエスト時に当番ガチャを実行する
// (トークン認証を行い、特定のSlackチャンネルからのリクエストのみ受付)
function doPost(e) {
  const verifyToken = PropertiesService.getScriptProperties().getProperty('SLACK_OUTGOING_TOKEN');

  if (verifyToken !== e.parameter.token) {
    throw new Error("トークンが違います。");
  }
  dutyGacha();
}

Outgoing WebhookでGASにアクセスがあった時の処理はdoPost関数で記述できます。
最初にトークンで認証を行い、予期しないアクセスははじくようにしておき、その後にdutyGacha関数を実行します。

3.GASのコードを公開
GASエディタ上から 公開 → ウェブアプリケーションとして導入

  • Project version…公開するバージョンを指定。新しくバージョンを作る場合はNewを選択。
  • Execute the app as…実行時にどのアカウントで実行するか。Meを選択。
  • Who has access to the app…このアプリケーションへのアクセス権限。Outgoing Webhookからのアクセスを受け入れるためにAnyone, even anonymousを選択。

公開するとCurrent web app URLが表示されるので控えておきます。

再度Slack側 #

インテグレーションの設定の続きを行います。

  1. インテグレーションの設定
    • チャンネル…どのチャンネルから実行できるようにするか選択
    • 引き金となる言葉…トリガーとなる言葉を入力(例:がちゃる)
    • URL…先ほど控えたGASアプリケーションのURLを記述
    • 名前やアイコンはそのままで問題なし

Slack側から呼び出し #

ここまで来たら、Slackの指定したチャンネルからトリガーワードで呼び出せるようになっているはずです。
呼び出してみましょう。うまくメッセージが投稿され、スプレッドシートに履歴が記述されたでしょうか。

スプレッドシート上でガチャ実行 #

スプレッドシート上でもガチャを実行したいという場合は、適当な図形を作成して、それにスクリプトを割り当てると実現できます。

  1. スプレッドシート上で 挿入 → 図形描画 で適当な図形を作成してシートに挿入
  2. 挿入した図形を右クリックで選択し、三点リーダからスクリプトの割り当てを選択
  3. dutyGacha関数を指定

後から設定をカスタマイズしたい時は #

例として抽選する当番の人数を増やしたい時は

  1. スプレッドシートの当番履歴表の右側に列を追加する
  2. GASのdutyGacha関数の最初で初期化している以下の変数の値を合わせる
    • historyDataColumnRangeEnd…変数の値を、追加した列に合わせる
    • dutyMemberNum…新しい当番人数を指定
  3. GASを再度公開する(公開 → ウェブアプリケーションとして導入 → Newバージョンで指定して公開)

特に、3のGASの再度公開は忘れがちなので注意して下さい。

基本的には、何かしらGASのコードを更新したら再度公開するようにすれば大丈夫かと思います。

Webhookのやり方が変わった? #

このガチャを作成した後に知ったのですが、今回使用したWebhookの方式がどうやら旧方式になるらしく、新方式としてSlack Appを個別に作成し、そのなかでWebhookの設定をするものになったそうです。
slackのIncoming webhookが新しくなっていたのでまとめてみた

すぐに旧方式が使えなくなるようではないですが、将来的にはなくなるようなので、いずれは方式を新しくしておきたいですね。

新方式もちょっとやってみたのですが、Incomming Webhookはすんなりできたのに、Outgoing Webhookの代わりとなると思われるEvent Subscriptionsがよくわかりませんでした。
トリガーワードが設定できないっぽい…?
message.channelsのイベントを設定したら、メッセージ投稿で反応するっぽい?ですが、投稿の度に毎回GASにアクセスされるとしたら微妙なような…。
スラッシュコマンドとかでやった方がいいんですかね、う~ん。


連携させることで、GASからスプレッドシートの操作を行うことができるのは便利ですが、元となるスプレッドシートの状態がおかしかったりすると、うまく動作しなくなる問題があります。
スプレッドシートの変化に対応できるようなGASを書くことも考えたのですが、どこまで対応すべきなのか?というところを悩んだ結果、dutyGacha関数の最初で初期化することで、せめて修正しやすくしようとしてみました。
ここらへんってどうすべきなんでしょうね…。悩ましいところです。

また何か作るネタを思いついたらGASで作ってみようかと思いますー。

参考リンクまとめ #