7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-06-07

[2022/01/17 更新]

はじまり

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

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

完成品

まず先に見せちゃいますね。

まず「App」下の日報マンのホームタブから「作成」ボタンをクリックします。
1.png

モーダルが表示されるので、そこに日報の内容を入力し、「Send」をクリックします。
2.png

すると、特定のチャンネルにメッセージが届くので、このURLをクリックします。
3.png

入力した内容がGoogleスプレッドシートに書き込まれてました。
4.png

環境

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

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

フロー

日報投稿時のデータの流れですね。
flow.png.001.png

作成の流れ

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

Slack API

slack apiにてアプリを作成します。

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:writeincomming-webhookを追加

Event Subscriptions

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

glitch

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

appHome.js ソースを見る
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);
  }
};

/**
 * 0埋め
 */
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 };

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

index.js ソースを見る
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);
  });
}

送受信の窓口。

これ以外に、SlackAPIの設定画面で取得したトークンやGASの送信先URLなどを.envに格納しています。
モーダルのコードは、まずはBlock Kit Builderで作成するのがわかりやすくておすすめです。

Google Apps Script (GAS)

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

index.gs ソースを見る

/**
 * 在宅勤務日報作成 -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}`)
}
GASでは特にファイルは分けてません。

ハマったこと

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

SlackAPI

どこから手をつけていいかわからない

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

glitch

モーダルに入力した値を拾う方法が分からない

1階層目をactions、2階層目をdatepickerx2にした時のdatepickerの値の取得が出来ず。
結局、1階層目をinputs、2階層目をdatepickerにしたものを2つ作りました。
actionsブロックって値取れないの??

GAS

Webアプリケーションとして公開する際にProject versionを"New"にしないと、外部に反映されない

ハマりましたね〜。
Project versionの意味がわからないまま公開していたため、修正するたび「あれ?なんで出来ないの!?」ってなってました。

おわり

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

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

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

7
7
5

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?