[2022/01/17 更新]
はじまり
うちの会社では在宅勤務で1日を終える際、日報を提出しています。
提出の仕方は、Slackの勤怠管理用のチャンネルに、あらかじめ指定されたフォーマットのファイルを投稿するというもの。
書いて提出する方はまだいいんだけど、管理する人が大変そう。
じゃあ 勉強がてらなんか作ったるか っていうノリで作りました。
完成品
まず先に見せちゃいますね。
まず「App」下の日報マンのホームタブから「作成」ボタンをクリックします。
モーダルが表示されるので、そこに日報の内容を入力し、「Send」をクリックします。
すると、特定のチャンネルにメッセージが届くので、このURLをクリックします。
入力した内容がGoogleスプレッドシートに書き込まれてました。
環境
すべてWebサービスなので環境には囚われませんが、一応書いておきますね。
- OS:Ubuntu 18.04LTS KDE
- ブラウザ:Vivaldi 3.0
- 使用ツール:SlackAPI、glitch、Google Apps Script(以下、GAS)
フロー
作成の流れ
実際に書いたソースの一部を見せて説明します。
こっからはなげーですので了承してください。
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:write
とincomming-webhook
を追加
Event Subscriptions
- 「Request URL」に
https://"glitchプロジェクトのURL".glitch.me/slack/events
を入力
glitch
圧倒的色使いと魚力。
glitchにプロジェクトを作成してコードを書きます。めんどい人は、似た物を作って公開している他人のプロジェクトをパクって(Remixして)ください。
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 ソースを見る
/**
* イベント
*/
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}`)
}
ハマったこと
今回のシステム作成の流れでハマったことを共有しときます。
SlackAPI
どこから手をつけていいかわからない
SlackAPIのページのどこにコードを書いていいかも、自前でサーバーを用意する必要があるかもわからなかったです。
でも公式にちゃんといろんな作成例が日本語ドキュメントで載ってるんですね。
glitch
モーダルに入力した値を拾う方法が分からない
1階層目をactions
、2階層目をdatepicker
x2にした時のdatepicker
の値の取得が出来ず。
結局、1階層目をinputs
、2階層目をdatepicker
にしたものを2つ作りました。
actions
ブロックって値取れないの??
GAS
Webアプリケーションとして公開する際にProject version
を"New"にしないと、外部に反映されない
ハマりましたね〜。
Project version
の意味がわからないまま公開していたため、修正するたび「あれ?なんで出来ないの!?」ってなってました。
おわり
なかなか時間かかってしまったからか、いい達成感を得られました。
会社の事務作業の効率化でも、なんかの参考になればいいですね。
あと関係ないけどQiitaのマークダウンがパワーアップしてて驚きました。
ここまで読んでくれた人、ありがとうございました。