消灯当番を決めてくれるSlackのBotを作る③　～トリガーで決まった時間に当番を発表編～

#前回のあらすじ
前回：https://qiita.com/Hiroyasu_Sato/items/08a445f27ec77d052a02

前回はスプレッドシートとGASを用いて、スタンプを拾って電話の回数を集計する処理を作りました。

今回は最終段階として、自動で当番を決定する処理と、**トリガー**で決まった曜日と時刻に関数を走らせる処理を作っていきたいと思います。

[消灯当番を決めてくれるSlackのBotを作る①　～とりあえず返事だけしてくれ編～](https://qiita.com/Hiroyasu_Sato/items/2ede192de989e0c5065e)の序盤に紹介したルールを振り返ります。

1. 毎週金曜日の正午に、電話応対の回数の集計を締め切り、選出を行う。
2. 電話応対の回数が少なかった人から順に任命。
3. 同率で人数が溢れた場合は抽選。
4. 全員の回数の合計が、1年目社員の総合人数を下回った場合は、電話応対の回数に関わらず全員で抽選。
5. 任命が終了したら集計をリセット。再び翌週の金曜日の正午まで集計を行う。

1番に関しては、GASの関数を作ってから設定するので、2番から考えていきます。
#当番を決定して発表する
今回作る処理は複雑なので、先にフローチャートで整理しました。処理の大体の流れは以下の通りです。
![Untitled Diagram.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/e24420da-2c23-3fe1-1d7b-7632f4c5d02d.jpeg)
上から順に実装を見ていきます。
##最終結果を発表
こちらの処理は前回の中間発表の処理と同じ感じなので、解説は省きます。
<img width="265" alt="3-5.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/de255ce9-2b62-00f5-9c92-dcf0f9e25159.png">

```js:
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;

var ROW_FIRST = 2;

var COL_DISPLAY_NAME = 2;

// 結果を投稿
function postResult() {
  slack("金曜日の正午になりました、\n来週の当番を決定します。");
  
  var text = "";
  var results = getResult();
  
  for(var i = 0; i < MEMBERS_NUM; i++){
    text += getTextNameCount(results[i]);
  }
  
  slack(text);
}

// 表示名と回数のデータを二次元配列で返却
function getResult() {
  return SHEET.getRange(ROW_FIRST, COL_DISPLAY_NAME, MEMBERS_NUM, 2).getValues();
}

// 社員ごとの結果テキストを返却
function getTextNameCount(data) {
  return data[0] + ":" + data[1] + "回\n";
}
```

~~前回の中間発表の処理とロジックが違うのはナイショ~~
##合計の数で分岐
続いて、**合計回数が人数未満か**を判定し、処理を分岐します。

```js
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;

var ROW_SUM = 8;

var COL_COUNT = 3;

// 決定した当番を発表する
function postSwitchers() {
  // 合計回数
  var totalCount = SHEET.getRange(ROW_SUM, COL_COUNT).getValue();
  // 当番の表示名を格納するための配列
  var switchers = [];
  // 合計回数が人数（6人）より少ないかどうか
  switchers = (totalCount < MEMBERS_NUM) ? decideSwitchersByAll() : decideSwitchersSinceWorst();
  
  slack(getTextSwitchers(switchers));
}
```
例によって命名のセンスはお許しください....。

詳しい処理の内容は後述しますが、switchersに三項演算子で代入している行にある「**decideSwitchersByAll**」と「**decideSwitchersSinceWorst**」は、両方とも配列を返すようになっています。その配列を引数にして、当番を発表するテキストを作っているのが「**getTextSwitchers**」です。

それぞれの処理の内容を詳しく見ていきます。
##全員で抽選する(decideSwitchersByAll)
全体的な処理より先に、抽選をする処理から解説したいと思います。

こちらの抽選処理は、後述するdecideSwitchersSinceWorstの中でも使うため、なるべく汎用性のある関数にしたいと思いました。
よって、抽選関数**selectNamesBySlot**では、**引数に抽選元の配列**と**選び抜く人数**を引数に渡すことにしました。

```js
// 指定した人数の社員をランダムで抽出
// names：社員の名前が入った配列
// num：選出する人数
function selectNamesBySlot(names, num) {
  // 返り値用の配列
  var result = [];
  // 乱数を入れる変数
  var n;
  
  for(var i = 0; i < num; i++) {
    // ランダムに整数を選ぶ
    n = Math.floor(Math.random() * names.length);
    // 抽選元から選んだ1人を抜き、結果の配列に格納
    result.push(names.splice(n, 1));
  }

  return result;
}
```
Mathクラスは、数学に関する関数がたくさんあるものです（適当）
**Math.floor**は、引数の値の**小数点以下を切り下げ**、整数を返します。
**Math.random**は、**0以上1未満の実数**をランダムに返します。

今回の処理では、Math.randomに人数（今回なら6人）を掛けることで、0以上6未満の実数をランダムに決め、Math.floorで切り捨てることで、0～5の整数にすることができます。~~たぶん~~同様に確からしいと思います。

そうしてランダムに決まった整数を配列の要素番号として扱い、社員を選出しています。

この関数を使って、全員での抽選を実装します。

```js
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;
var SWITCHERS_NUM = 2;

var ROW_FIRST = 2;

var COL_DISPLAY_NAME = 2;

// 全員で抽選
function decideSwitchersByAll() {
  var names = SHEET.getRange(ROW_FIRST, COL_DISPLAY_NAME, MEMBERS_NUM).getValues();
  
  slack("合計回数が人数未満でした。");
  
  return selectNamesBySlot(names, SWITCHERS_NUM);
}
```
これだけです。シンプルですね。
##最下位から順に選出(decideSwitchersSinceWorst)
この処理を実装するにあたり、どんな情報が必要かというのを事前に考えました。

- 全員の順位
- 最下位の順位
- 同率の社員の人数

など、ランキングに関する情報が必要なのですが、これをプログラムコードの中で判断するのは結構大変です。

どうしよう....何か上手い方法ないかな....と思っていたら、ある考えがよぎりました。

**スプレッドシートでやればいいじゃん....**

<img width="568" alt="3-6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/b0c6ab38-ed89-94a0-88c0-7ed356be6ac5.png">

はい、ということで、スプレッドシートの関数式で済ますことにしました。
それぞれの式は以下のようになっています。

D列：`=RANK(C2,C$2:C$7,FALSE)`
E列：`=MAX(D2:D7)`
G列：`=COUNTIF(D$2:D$7,F2)`

```js
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;
var SWITCHERS_NUM = 2;

var ROW_FIRST = 2;
var ROW_SUM = 8;

var COL_DISPLAY_NAME = 2;
var COL_RANK = 4;
var COL_WORST_RANK = 5;
var COL_RANK_NUMBER = 6;
var COL_RANK_COUNT = 7;

// 最下位から決めていく
function decideSwitchersSinceWorst() {
  // 返り値用の配列
  var switchers = [];
  // 残りの当番の枠
  var remainCnt = SWITCHERS_NUM;
  // 参照する順位
  var currentRank = getWorstRank();
  // 作業用の配列
  var names = [];
  
  // 枠がなくなるまで
  while(remainCnt > 0){
    // 対象の順位の名前を抽出
    names = getNamesByRank(currentRank);
    
    // 枠から溢れた場合は抽選
    if(getRankCount(currentRank) > remainCnt) {
      names = selectNamesBySlot(names, remainCnt);
    }
    
    // 結果の配列に加える
    switchers = pushSwitchers(switchers, names);
    // 対象の順位（の数字）を1つ落とす
    currentRank--;
    // 枠を減らす
    remainCnt -= names.length;
  }
  
  return switchers;
}

// 最低順位を取得
function getWorstRank() {
  return SHEET.getRange(ROW_FIRST, COL_WORST_RANK).getValue();
}

// 指定した順位の同率人数を取得
function getRankCount(rank) {
  return SHEET.getRange(ROW_FIRST + rank - 1, COL_RANK_COUNT).getValue();
}
  
// 当番の配列を結合
function pushSwitchers(switchers, names) {
  for(var i in names) {
    switchers.push(names[i]);
  }
  
  return switchers
}

// 指定したランクの社員の名前を配列で取得
function getNamesByRank(rank) {
  var names = [];
  
  for(var i = 0; i < MEMBERS_NUM; i++) {
    if(SHEET.getRange(i + ROW_FIRST, COL_RANK).getValue() == rank) {
      names.push(SHEET.getRange(i + ROW_FIRST, COL_DISPLAY_NAME).getValue());
    }
  }
  
  return names;
}
```
少々複雑ですが....これで当番を選出する処理が実装できました！
##選出結果を投稿する
選出した結果をSlackに投稿するために、テキストを生成します。

```js
// 当番発表用のテキストを返却
function getTextSwitchers(switchers) {
  var text = "来週の消灯当番は、\n";
  
  for(var i in switchers) {
    text += switchers[i] + "\n";
  }
  
  text += "の" + SWITCHERS_NUM + "人です。\nよろしくお願いします。";
  
  return text;
}
```
こうして、`slack(getTextSwitchers(switchers))`と呼び出せば、Slackに自動で投稿してくれます。
##データをリセットする
こちらは至ってシンプルで、回数の列に0を埋めていくだけとなります。

```js
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;

var ROW_FIRST = 2;

var COL_COUNT = 3;

// 回数をリセット
function reset() {
  for(var i = 0; i < MEMBERS_NUM; i++) {
    SHEET.getRange(i + ROW_FIRST, COL_COUNT).setValue(0);
  }
  
  slack("データをリセットしました。");
}
```

最後にこれらの関数を順番通りに実行するよう、コントローラーを実装して完了です。

```js
// 全体の流れ
function postAll() {
  postResult();
  postSwitchers();
  reset();
}
```
##動作確認
ちゃんと選出してくれるかを確認します

全員で抽選する場合
![Qiita3-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/81142b1d-0f69-a880-4006-1c852ea2a4ed.png)

同率が溢れた場合１
![Qiita3-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/15b71655-9870-bb3f-ad3f-cd674ebf9d28.png)

同率が溢れた場合２
![Qiita3-5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/f2dd7ee6-3a67-4a89-3e26-da6797eefc38.png)

抽選が必要ない場合
![Qiita3-6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/f07b4a55-0d5b-c39e-80c6-f94ff1c8abfc.png)

良さそうですね！

#曜日と時刻を指定して実行させる
まだ終わりではありません。自動で実行してくれるように設定を行います。

GASのページのメニューに、時計のマークのボタンがあるのでクリックすると、トリガー設定のページが別タブで開かれます。
![Qiita3-7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/5c79eb1d-1714-b097-1b90-7079dbd40fe7.png)

「トリガーを追加」を押し、設定モーダルを表示させます。
<img width="744" alt="3-1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/87b76bc6-7a31-e0eb-a12c-f1ae47739aac.png">

ここで毎週金曜日の正午に....ん？
![Qiita3-8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/0db2f992-ff4f-42fe-0d30-e0f57450ac70.png)

午前１１時～午後１２時....？

そうなんです、このトリガー、週ごとや日ごとだと**具体的な時刻を指定できません**。困りましたね....。

こんなときは、**GASにトリガーを設定する関数を実装すれば良いのです！**

```js
// トリガーをセット（正午）
function setTriggerSelect() {
  var triggerDay = new Date();
  
  triggerDay.setHours(12);
  triggerDay.setMinutes(0);
  ScriptApp.newTrigger("postAll").timeBased().at(triggerDay).create();
}
```
詳しいことは~~分からないので~~省きますが、この関数では「その日の12:00にpostAllを実行する」というトリガーを設定しています。
そして、トリガー設定画面では、setTriggerSelectを正午より前に実行するように設定します。

![Qiita3-9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/50ef1a53-1d89-45d0-ef6b-9d815f2beea3.png)

<img width="738" alt="3-4.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/416f157c-937b-c40c-5ffc-e85a7f1b63dc.png">


また、setTriggerSelectで追加したトリガーは、消去しないとずっと残ってしまうので、削除する関数も作ります。

```js
// トリガーを削除
function deleteTriggerSelect() {
  var triggers = ScriptApp.getProjectTriggers();
  
  for(var i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() == "postAll") {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}
```

これを、postAllの中で呼び出すようにします。

```js
function postAll() {
  postResult();
  postSwitchers();
  reset();
  // トリガーを削除
  deleteTriggerSelect();
}
```

これで完了です！

<details>
<summary>最終的なコード</summary>
<div>

```js:Decide.gs
var SHEET = SpreadsheetApp.getActiveSheet();
var MEMBERS_NUM = 6;
var SWITCHERS_NUM = 2;

var ROW_FIRST = 2;
var ROW_SUM = 8;

var COL_USER_NAME = 1;
var COL_DISPLAY_NAME = 2;
var COL_COUNT = 3;
var COL_RANK = 4;
var COL_WORST_RANK = 5;
var COL_RANK_NUMBER = 6;
var COL_RANK_COUNT = 7;

// トリガーをセット（正午）
function setTriggerSelect() {
  var triggerDay = new Date();
  
  triggerDay.setHours(12);
  triggerDay.setMinutes(0);
  ScriptApp.newTrigger("postAll").timeBased().at(triggerDay).create();
}

// トリガーを削除
function deleteTriggerSelect() {
  var triggers = ScriptApp.getProjectTriggers();
  
  for(var i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() == "postAll") {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

// 全体の流れ
function postAll() {
  postResult();
  postSwitchers();
  reset();
  deleteTriggerSelect();
}

// 結果を投稿
function postResult() {
  slack("金曜日の正午になりました、\n来週の当番を決定します。");
  
  var text = "";
  var results = getResult();
  
  for(var i = 0; i < MEMBERS_NUM; i++){
    text += getTextNameCount(results[i]);
  }
  
  slack(text);
}

// 表示名と回数のデータを二次元配列で返却
function getResult() {
  return SHEET.getRange(ROW_FIRST, COL_DISPLAY_NAME, MEMBERS_NUM, 2).getValues();
}

// 社員ごとの結果テキストを返却
function getTextNameCount(data) {
  return data[0] + ":" + data[1] + "回\n";
}

// 決定した当番を発表する
function postSwitchers() {
  var totalCount = SHEET.getRange(ROW_SUM, COL_COUNT).getValue();
  var switchers;
  
  switchers = (totalCount < MEMBERS_NUM) ? decideSwitchersByAll() : decideSwitchersSinceWorst();
  
  slack(getTextSwitchers(switchers));
}

// 全員で抽選
function decideSwitchersByAll() {
  var names = SHEET.getRange(ROW_FIRST, COL_DISPLAY_NAME, MEMBERS_NUM).getValues();
  
  slack("合計回数が人数未満でした。");
  
  return selectNamesBySlot(names, SWITCHERS_NUM);
}

// 最下位から決めていく
function decideSwitchersSinceWorst() {
  var switchers = [];
  var remainCnt = SWITCHERS_NUM;
  var currentRank = getWorstRank();
  var names = [];
  
  while(remainCnt > 0){
    names = getNamesByRank(currentRank);
    
    if(getRankCount(currentRank) > remainCnt) {
      names = selectNamesBySlot(names, remainCnt);
    }
    
    switchers = pushSwitchers(switchers, names);
    currentRank--;
    remainCnt -= names.length;
  }
  
  return switchers;
}

// 最低順位を取得
function getWorstRank() {
  return SHEET.getRange(ROW_FIRST, COL_WORST_RANK).getValue();
}

// 指定した順位の同率人数を取得
function getRankCount(rank) {
  return SHEET.getRange(ROW_FIRST + rank - 1, COL_RANK_COUNT).getValue();
}
  
// 当番の配列を結合
function pushSwitchers(switchers, names) {
  for(var i in names) {
    switchers.push(names[i]);
  }
  
  return switchers
}

// 指定したランクの社員の名前を配列で取得
function getNamesByRank(rank) {
  var names = [];
  
  for(var i = 0; i < MEMBERS_NUM; i++) {
    if(SHEET.getRange(i + ROW_FIRST, COL_RANK).getValue() == rank) {
      names.push(SHEET.getRange(i + ROW_FIRST, COL_DISPLAY_NAME).getValue());
    }
  }
  
  return names;
}

// 指定した人数の社員をランダムで抽出
function selectNamesBySlot(names, num) {
  var result = [];
  var n;
  
  postBeginSlot(names);
  
  for(var i = 0; i < num; i++) {
    n = Math.floor(Math.random() * names.length);
    result.push(names.splice(n, 1));
  }
  
  postSlotResult(result);
  
  return result;
}

// 抽選を宣言
function postBeginSlot(names) {
  var text = "";
  
  for(var i in names) {
    text += names[i] + "\n";
  }
  
  text += "この" + names.length + "人で抽選を行います。";
  
  slack(text);
}

// 抽選結果を投稿
function postSlotResult(names) {
  var text = "";
  
  for(var i in names) {
    text += names[i] + "\n";
  }
  
  slack(text);
}

// 当番発表用のテキストを返却
function getTextSwitchers(switchers) {
  var text = "来週の消灯当番は、\n";
  
  for(var i in switchers) {
    text += switchers[i] + "\n";
  }
  
  text += "の" + SWITCHERS_NUM + "人です。\nよろしくお願いします。";
  
  return text;
}

// 回数をリセット
function reset() {
  for(var i = 0; i < MEMBERS_NUM; i++) {
    SHEET.getRange(i + ROW_FIRST, COL_COUNT).setValue(0);
  }
  
  slack("データをリセットしました。");
}
```
</div>
</details>

#最終動作確認

setTriggerSelectが動作したことを確認
![Qiita3-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/6f8fcf37-4f20-f8c1-5ccc-cd0753045809.png)
（木曜になってますが気にしないでください）

いよいよ正午、当番を発表
![Qiita3-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/1c138873-b294-e4da-2ac1-a4b887575857.png)

データのリセットを確認
![Qiita3-11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/d57b7e0a-1b48-64e5-40a5-c137d792310e.png)

ついに完成です...！！

#感想
- SlackBotを作るにあたり、何を使えば良いのか分からない状態からのスタートだったので、そこが一番苦労したと思います。
- コーディングは面白かったですが、動作チェックの方法が分からなかったり、エラーの原因が探しにくいのは大変でした。
- 実は今回のQiita投稿のために、実装は一からやり直しています。過去に作ったコードに比べ、読みやすく弄りやすいモノになったと思いますし、少なからずの成長だと感じています。
- まだまだGoogle DriveとGASとの機能連携で知らないことがあると思うので、これからも便利なツールを作っていきたいです。

#参考
Google Apps Scriptの日毎のトリガーで時間をもっと細かく設定する
https://qiita.com/sumi-engraphia/items/465dd027e17f44da4d6a

#追伸
消灯くんの現在
![Qiita3-12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/431212/35da8d96-84af-d038-5d59-230eedfe410c.png)
どうしてこうなった....。