Slackで在宅勤務の日報を投稿しちゃう

[2022/01/17 更新]

# はじまり
うちの会社では在宅勤務で１日を終える際、日報を提出しています。
提出の仕方は、Slackの勤怠管理用のチャンネルに、あらかじめ指定されたフォーマットのファイルを投稿するというもの。
書いて提出する方はまだいいんだけど、管理する人が大変そう。

じゃあ **勉強がてらなんか作ったるか** っていうノリで作りました。


# 完成品
まず先に見せちゃいますね。

まず「App」下の**日報マン**のホームタブから「作成」ボタンをクリックします。
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/ba37ba84-f9c1-d398-d3a9-9be03c7dbf1a.png)

モーダルが表示されるので、そこに日報の内容を入力し、「Send」をクリックします。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/3370ae4a-33c5-43e3-3692-45c10cbda928.png)

すると、特定のチャンネルにメッセージが届くので、このURLをクリックします。
![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/caaed523-edec-60a2-58e1-99f675b17b06.png)

入力した内容がGoogleスプレッドシートに書き込まれてました。
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/3a340a5d-6aeb-d86a-05d9-63f65625409f.png)


# 環境
すべてWebサービスなので環境には囚われませんが、一応書いておきますね。

- OS：Ubuntu 18.04LTS KDE
- ブラウザ：Vivaldi 3.0
- 使用ツール：SlackAPI、glitch、Google Apps Script(以下、GAS)


# フロー
日報投稿時のデータの流れですね。
![flow.png.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/500613/3e4f769c-891c-e8e5-82c0-bcd165fed3a0.png)


# 作成の流れ
実際に書いたソースの一部を見せて説明します。
こっからはなげーですので了承してください。

## Slack API

[slack api](https://api.slack.com)にてアプリを作成します。

### Basic Information

- 「App name」と「Short description」を入力
- 「Signing Secret」はコピーしてglitchの`.env`に格納

### Install App

- 「Install App」で、Slackのワークスペースで追加できるようになる

### App Home

- 「Always Show My Bot as Online」と「Home Tab」を有効化

### Incoming Webhooks

- 「Add New Webhook to Workspace」で投稿結果の通知先チャンネルを指定。URLはコピーしてGASに格納

### Interactivity & Shortcuts

- 「Request URL」に`https://"glitchプロジェクトのURL".glitch.me/slack/actions`を入力

### OAuth & Permissions

- 「Bot User OAuth Access Token」はコピーしてglitchの`.env`に格納
- 「Bot Token Scopes」で`chat:write`と`incomming-webhook`を追加

### Event Subscriptions

- 「Request URL」に`https://"glitchプロジェクトのURL".glitch.me/slack/events`を入力


## glitch

圧倒的色使いと魚力。
[glitch](https://glitch.com)にプロジェクトを作成してコードを書きます。めんどい人は、似た物を作って公開している他人のプロジェクトをパクって(Remixして)ください。

<details><summary>appHome.js ソースを見る</summary><div>

```appHome.js
/**
 * Homeタブ本体
 */
const updateView = async(user) => {
  // Block
  let blocks = [ 
    {
      type: "section",
      text: { type: "mrkdwn", text: "*在宅勤務日報* \n :memo: 今日の日報を書きましょう！" },
      accessory: {
        type: "button",
        action_id: "add_note", 
        text: { type: "plain_text", text: "作成", emoji: true }
      }
    },
    { type: "divider" }
  ];

/* 〜省略〜 */

  // Final view.
  let view = {
    type: 'home',
    title: {type: 'plain_text',text: 'Keep notes!'},
    blocks: blocks
  }
  return JSON.stringify(view);
};

/**
 * Homeタブ表示
 */
const displayHome = async(user, data) => {
  if(data) {     
    // Store in a local DB
    db.push(`/${user}/data[]`, data, true);   
  }
  // POSTデータ
  const args = {
    token: process.env.SLACK_BOT_TOKEN,
    user_id: user,
    view: await updateView(user)
  };
  // POST送信
  const result = await axios.post(`${apiUrl}/views.publish`, qs.stringify(args));

  try {
    if(result.data.error) {
      console.log(result.data.error);
    }
  } catch(e) {
    console.log(e);
  }
};

/**
 * ０埋め
 */
const zeroPadding = (number) => {
  return ( '00' + number ).slice( -2 )
}

/**
 * 今日の日付取得
 */
const getToday = async () => {
  const today = new Date();
  return `${today.getFullYear()}-${zeroPadding(today.getMonth() + 1)}-${zeroPadding(today.getDate())}`
}

/**
 * 時間ブロック作成
 */
const createTimeBlock = async (hour, minutes) => {
  const timeBlock = {
    "text": {
      "type": "plain_text",
      "text": `${zeroPadding(hour)} : ${zeroPadding(minutes)}`,
      "emoji": true
    },
    "value": `${zeroPadding(hour)}:${zeroPadding(minutes)}`
  };
  return timeBlock;
};

/**
 * 時間ブロックリスト作成
 */
const createTimeList = async () => {
  let blockList = [];
  const start = 0
  const end = 23
  // push list.
  for (let hour = start; hour <= end; hour++) {
    blockList.push(await createTimeBlock(hour, 0));
    blockList.push(await createTimeBlock(hour, 30));
  };
  return blockList;
};

/**
 * モーダル表示
 */
const openModal = async(trigger_id) => {
  const starthour = 9;
  const endhour = 18;
  // モーダル本体
  const modal = {
    "type": "modal",
    "title": { "type": "plain_text", "text": "在宅勤務日報", "emoji": true },
    "submit": { "type": "plain_text", "text": "Send", "emoji": true },
    "blocks": [
      // 勤務日
      {
        "type": "input",
        "block_id": "date",
        "element": {
          "type": "datepicker",
          "action_id": "date",
          "initial_date": await getToday(),
          "placeholder": { "type": "plain_text", "text": "Select a date", "emoji": true }
        },
        "label": { "type": "plain_text", "text": "勤務日時", "emoji": true }
      },
      // 開始時間 SelectBox
      {
        "type": "input",
        "block_id": "start",
        "element": {
          "type": "static_select",
          "action_id": "time",
          "placeholder": { "type": "plain_text", "text": "hour", "emoji": true },
          "initial_option": await createTimeBlock(starthour, 0),
          "options": await createTimeList()
        },
        "label": { "type": "plain_text", "text": "開始時間", "emoji": true }
      },
      // 終了時間 SelectBox
      {
        "type": "input",
        "block_id": "end",
        "element": {
          "type": "static_select",
          "action_id": "time",
          "placeholder": { "type": "plain_text", "text": "hour", "emoji": true },
          "initial_option": await createTimeBlock(endhour, 0),
          "options": await createTimeList()
        },
        "label": { "type": "plain_text", "text": "終了時間", "emoji": true }
      },
      // 勤務内容 TextBox
      {
        "type": "input",
        "block_id": "note01",
        "element": {
          "action_id": "content",
          "type": "plain_text_input",
          "placeholder": { "type": "plain_text", "text": "Take a note... " },
          "multiline": true
        },
        "label": { "type": "plain_text", "text": "勤務内容" }
      },
      // 進捗状況 TextBox
      {
        "type": "input",
        "block_id": "note02",
        "element": {
          "action_id": "content",
          "type": "plain_text_input",
          "placeholder": { "type": "plain_text", "text": "Take a note... " },
          "multiline": true
        },
        "label": { "type": "plain_text", "text": "進捗状況" }
      }
    ]
  };
  // 送信データ
  const args = {
    token: process.env.SLACK_BOT_TOKEN,
    trigger_id: trigger_id,
    view: JSON.stringify(modal)
  };
  // POST モーダルの表示
  const result = await axios.post(`${apiUrl}/views.open`, qs.stringify(args));
};

// エクスポート
module.exports = { displayHome, openModal };
```
</div></details>

ホームタブやモーダルを定義。

<details><summary>index.js ソースを見る</summary><div>

```index.js
/**
 * イベント
 */
app.post('/slack/events', async(req, res) => {
  switch (req.body.type) {
    // url_verificationを検知
    case 'url_verification': {
      // verify Events API endpoint by returning challenge if present
      res.send({ challenge: req.body.challenge });
      break;
    }
    // event_callbackを検知
    case 'event_callback': {
      // 署名があかんやつ
      if (!signature.isVerified(req)) {
        res.sendStatus(404);
        return;
      }
      // 署名がいいやつ
      else {
        const {type, user, channel, tab, text, subtype} = req.body.event;
        // app_home_openedを検知
        if(type === 'app_home_opened') {
          // appHome.jsの内容を表示
          appHome.displayHome(user);
        }
      }
      break;
    }
    default: { res.sendStatus(404); }
  }
});

/**
 * アクション
 */
app.post('/slack/actions', async(req, res) => {
  const { token, trigger_id, user, actions, type } = JSON.parse(req.body.payload);
  // ホームタブの「作成」ボタンクリック
  if(actions && actions[0].action_id.match(/add_/)) {
    // モーダル表示
    appHome.openModal(trigger_id);
  } 
  // モーダルの「Send」ボタンクリック
  else if(type === 'view_submission') {
    res.send('');
    // POSTするデータの作成
    const ts = new Date();
    const { user, view } = JSON.parse(req.body.payload);
    const postURL = process.env.GAS_URL_KINTAI;
    const data = {
      'user': user.name,
      'date': view.state.values.date.date.selected_date,
      'st_time': view.state.values.start.time.selected_option.value,
      'ed_time': view.state.values.end.time.selected_option.value,
      'note1': view.state.values.note01.content.value,
      'note2': view.state.values.note02.content.value,
      'channel': process.env.CHANNEL_ID
    };
    const messages = {
      'ok': '在宅勤務日報を提出します。',
      'ng': '【NG】 日報作成失敗しました。'
    };
    // Google Apps ScriptにPOST
    postToGAS(postURL, data, messages);
  }
});

/*
 * Google Apps Script にデータをPOSTする
 */
function postToGAS(postUrl, json, messages) {
  axios.post(postUrl, json).then(response => {
    // 送信成功時、ワークフローの申請があったチャンネルにOKメッセージを投稿します
    postToSlackChannel(
      json.channel,
      messages.ok
    );
  }).catch(error => {
    if (error.response) {
      console.log(error.response.status); // 例：400
      console.log(error.response.statusText);  // 例: Bad Request
    }
    // 送信失敗時、ワークフローの申請があったチャンネルにNGメッセージを投稿します
    postToSlackChannel(
      json.channel,
      messages.ng
    );
    console.log('Error', error.message);
  });
}

/*
 * Slack の chat.postMessage APIで任意のチャンネルにデータを送信する
 */
function postToSlackChannel(channelID, message) {
  const baseUrl = "https://slack.com/api/chat.postMessage";
  const postUrl = baseUrl + "?" + 
    "token=" + process.env.SLACK_ACCESS_TOKEN + "&" +
    "channel=" + channelID + "&" +
    "text=" + encodeURIComponent(message) + "&" +
    "as_user=" + false;
  axios.post(
    postUrl
  ).then(response => {
  }).catch(error => {
    if (error.response) {
      console.log(error.response.status); // 例：400
      console.log(error.response.statusText);  // 例: Bad Request
    }
    console.log('Error', error.message);
  });
}
```

</div></details>

送受信の窓口。

これ以外に、SlackAPIの設定画面で取得したトークンやGASの送信先URLなどを`.env`に格納しています。
モーダルのコードは、まずは[Block Kit Builder](https://app.slack.com/block-kit-builder/)で作成するのがわかりやすくておすすめです。


## Google Apps Script (GAS)

まず最初に書き込み先のスプレッドシートを作成し、そこから[ツール]-[スクリプトエディタ]でGASのエディタが開きます。

<details><summary>index.gs ソースを見る</summary><div>

```javascript

/**
 * 在宅勤務日報作成 -Google Apps Script-
 */

// 定数
const SS_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/xxxxxxxxx/xxxxxxxxx/xxxxxxxxx';

/**
 * POST受信
 */
function doPost(e) {
  try {
    Logger.log('e: "%s"', JSON.stringify(e));
    // 受信したPOSTデータをJson形式で取得
    const postData = JSON.parse(e.postData.getDataAsString());
    // キーからスプレッドシートを取得
    const ss = SpreadsheetApp.openById(SS_KEY);
    // 名前からシートを取得
    var sheet = ss.getSheetByName(postData.user);
    // シートがなければ作成
    if (!sheet) {
      var sheet = ss.insertSheet(postData.user);
      createSheet(sheet);
    }
    // 最終行に書き込み
    sheet.appendRow([postData.date, postData.st_time, postData.ed_time, postData.note1, postData.note2]);
    // D,E列を折り返しにする
    const lastRow = sheet.getLastRow();
    sheet.getRange(lastRow, 4).setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
    sheet.getRange(lastRow, 5).setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
    // Slack通知を呼び出し
    postToSlack(SLACK_WEBHOOK_URL, buildSlackMessage(postData.user, buildSheetURL(ss, sheet)));
  } catch (exception) {
    Logger.log(exception);
    // Slack通知を呼び出し
    postToSlack(SLACK_WEBHOOK_URL, buildErrorMessage(exception));
  } finally {
    return ContentService.createTextOutput('返すぞ');
  }
}

/**
 * 新しいシートを作成
 */
function createSheet(sheet) {
  // 列タイトル
  sheet.appendRow(['勤務日', '開始時間', '終了時間', '業務内容', '進捗状況']);
  for (var i = 1; i <= 5; i++) {
    // 列幅
    switch (true) {
      case i == 1:
        sheet.setColumnWidth(i, 100);
        break;
      case i == 2 || i == 3:
        sheet.setColumnWidth(i, 75);
        break;
      case i == 4 || i == 5:
        sheet.setColumnWidth(i, 350);
        break;
    };
    // セル背景色
    sheet.getRange(1, i).setBackground("#666666");
    // 文字色
    sheet.getRange(1, i).setFontColor("#ffffff")
  };
}

/**
 * シートのURLを取得
 */
function buildSheetURL(ss, sheet) {
  var url = ss.getUrl();
  var id = sheet.getSheetId()
  return url + "#gid=" + id;
}

/**
 * Slack通知用のメッセージ作成
 */
function buildSlackMessage(name, text) {
  const message = '[' + name + '] 在宅勤務日報を提出します。' + '\n' +
                  '提出先URL：' + text;
  return message;
}

/**
 * Slack通知用のメッセージ作成
 */
function buildErrorMessage(text) {
  const message = '在宅勤務日報作成システムでエラーが発生しました。' + '\n' +
                  '【発生箇所】 Google Apps Script' + '\n' +
                  '【メッセージ】 ' + text;
  return message;
}

/**
 * Slack通知
 */
function postToSlack(postUrl, message) {
  try{
    const now = new Date();
    const username = '在宅勤務日報作成';
    const jsonData =
        {
          "username" : username,
          "text" : message
        };
    const payload = JSON.stringify(jsonData);
    const options = {
      "method" : "post",
      "contentType" : "application/json",
      "payload" : payload
    };
    UrlFetchApp.fetch(postUrl, options);
  } catch (exception) {
    Logger.log('postToSlack');
    Logger.log(exception);
  }
}

/**
 * doPost()のテスト
 */
function doPostTest() {
  var e = {
    "user" : "abe",
    "date" : "2020-06-07",
    "st_time" : "08:30",
    "ed_time" : "17:30",
    "note1" : "勤務内容だよ",
    "note2" : "進捗状況だよ",
    "channel" : "XXXXXXXXX"
  };
  doPost(e);
}

/**
 * createSheet()のテスト
 */
function test() {
  const USERNAME = 'test'
  // キーからスプレッドシートを取得
  const ss = SpreadsheetApp.openById(SS_KEY);
  // 名前からシートを取得
  //var sheet = ss.getSheetByName(USERNAME);
  // シートがなければ作成
  var sheet = ss.insertSheet(USERNAME);
  createSheet(sheet);
  //var column1Size = sheet.getColumnWidth(1);
  //var column2Size = sheet.getColumnWidth(2);
  //var column3Size = sheet.getColumnWidth(3);
  //var column4Size = sheet.getColumnWidth(4);
  //var column5Size = sheet.getColumnWidth(5);
  //Logger.log(`A:${column1Size} B:${column2Size} C:${column3Size} D:${column4Size} E:${column5Size}`)
}
```

</div></details>
GASでは特にファイルは分けてません。

# ハマったこと
今回のシステム作成の流れでハマったことを共有しときます。

## SlackAPI
### どこから手をつけていいかわからない
SlackAPIのページのどこにコードを書いていいかも、自前でサーバーを用意する必要があるかもわからなかったです。
でも公式にちゃんといろんな作成例が日本語ドキュメントで載ってるんですね。

## glitch
### モーダルに入力した値を拾う方法が分からない
１階層目を`actions`、２階層目を`datepicker`x2にした時の`datepicker`の値の取得が出来ず。
結局、１階層目を`inputs`、２階層目を`datepicker`にしたものを2つ作りました。
`actions`ブロックって値取れないの？？

## GAS
### Webアプリケーションとして公開する際に`Project version`を"New"にしないと、外部に反映されない
ハマりましたね〜。
`Project version`の意味がわからないまま公開していたため、修正するたび「あれ？なんで出来ないの！？」ってなってました。

# おわり
なかなか時間かかってしまったからか、いい達成感を得られました。
会社の事務作業の効率化でも、なんかの参考になればいいですね。

あと関係ないけどQiitaのマークダウンがパワーアップしてて驚きました。

ここまで読んでくれた人、ありがとうございました。