朝に散歩するための仕組みを GAS と Slack でつくる
作ったもの
GASコード全体(長いので注意!)
const WEBHOOK_URL = "https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ";
const CALENDAR_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXX@group.calendar.google.com";
const TASKLIST_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
const SHEET_ID = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
const MY_SHEETS = SpreadsheetApp.openById(SHEET_ID).getSheets();
const LOG_SHEET = MY_SHEETS[0];
const TASK_SHEET = MY_SHEETS[1];
//////////////////////////////
// slack 関係
//////////////////////////////
function send2slack(text, blocks, url) {
const payload = {
"text": text,
"blocks": blocks
}
const options = {
"method" : "POST",
"payload": JSON.stringify(payload)
}
UrlFetchApp.fetch(url, options);
}
function makeButton (s, v) {
return {
"type": "button",
"text": {
"type": "plain_text",
"text": s,
"emoji": true
},
"value": v
}
}
// 意志確認
function askWalking () {
const s = "明日 ノ 朝散歩:";
const blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": s
}
},
{
"type": "actions",
"block_id": "ask-walking",
"elements": [
makeButton("する","will"),
makeButton("しない","wont"),
]
}
];
send2slack(s, blocks, WEBHOOK_URL);
}
// 最終行の確認
function getLastLog(sheet) {
return sheet.getRange(sheet.getLastRow(), 1, 1, 2).getValues()[0];
}
// 散歩できたかの確認
function askResult () {
if (getLastLog(LOG_SHEET)[1] == "REST") {
return;
}
const s = "今朝 ノ 散歩:";
const blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": s
}
},
{
"type": "actions",
"block_id": "ask-result",
"elements": [
makeButton("できた","did"),
makeButton("できなかった","didnot"),
]
}
];
send2slack(s, blocks, WEBHOOK_URL);
}
// 歩数の確認
function askSteps (url) {
const s = "エライ! 目標 達成! :tada:\n\n今日 ハ ドレクライ 歩ケタ?";
const blocks = [
{
"dispatch_action": true,
"type": "input",
"block_id": "ask-step",
"element": {
"type": "plain_text_input"
},
"label": {
"type": "plain_text",
"text": s,
"emoji": true
}
}
];
send2slack(s, blocks, url);
}
// ボタン押しへの返信
function replyToAction(s, url) {
const blocks = [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": s
}
}];
send2slack(s, blocks, url);
}
//////////////////////////////
// カレンダーへの登録
//////////////////////////////
function recordOnCalendar(event){
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
const d = new Date();
calendar.createAllDayEvent(event, d);
}
//////////////////////////////
// Google task api
//////////////////////////////
function getDueStr(){
const today = new Date();
const dueStr = Utilities.formatDate(today, "Asia/Tokyo", "yyyy-MM-dd");
return dueStr + "T00:00:00.000Z";
}
function addTask(title, parent="", previous="") {
const dueStr = getDueStr();
const task = {
"title": title,
"due": dueStr
};
return Tasks.Tasks.insert(task, TASKLIST_ID, {"parent": parent, "previous": previous});
}
function getSubTasks(sheet) {
const data = sheet.getRange(1, 1, sheet.getLastRow(), 1).getValues();
return data.map(d => d[0]);
}
function prepareForTomorrow() {
const mainTask = addTask("散歩準備");
let prevId = "";
getSubTasks(TASK_SHEET).forEach(e => {
const subTask = addTask(e, mainTask.id, prevId);
prevId = subTask.id;
Utilities.sleep(200);
});
}
//////////////////////////////
// メイン処理
//////////////////////////////
function doPost(e) {
const payload = JSON.parse(e.parameter.payload);
const resUrl = payload.response_url;
const blockId = payload.actions[0].block_id;
const actionValue = payload.actions[0].value;
if (blockId == "ask-walking") {
if (actionValue == "will") {
LOG_SHEET.appendRow([new Date(), actionValue])
replyToAction("タスク ヲ 追加 シマス! :thumbsup:", resUrl);
prepareForTomorrow();
return;
}
LOG_SHEET.appendRow([new Date(), "REST"])
replyToAction("明日 ハ 足 ヲ 休メル 日! :thumbsup:", resUrl);
return;
}
if (blockId == "ask-result") {
if (actionValue == "did") {
askSteps(resUrl);
return;
}
replyToAction("残念… デモ 無理 シスギナイ ノモ エライ! :grin:", resUrl);
return;
}
if (blockId == "ask-step") {
const steps = payload.actions[0].value;
replyToAction(":white_check_mark: カレンダー ニ " + steps + "歩 トシテ 記録 シマシタ!", resUrl);
recordOnCalendar("運動:朝ウォーキング" + steps + "歩");
return;
}
}
14時頃にメッセージが届き、
「する」と回答した場合は Google Todo にタスクが登録されます。
翌朝に散歩できたかを聞いてきて、
実際は8時台に投稿されます
「できた」と答えるとさらに歩数を聞いてきます。
答えた歩数が Google カレンダーに登録されます。
やっていること
GAS(Google Apps Script)と Slack を使っています。
-
1
:14時ころに翌朝に散歩するか確認するメッセージを Slack に投稿 -
2
:「する/しない」を回答 -
3
:2
の内容を Google スプレッドシートに記録 -
4
:3
で「する」と回答した場合は Google Todo にタスク追加 -
5
:翌朝8時に散歩できたか否かを確認するメッセージを Slack に投稿 -
6
:「できた/できなかった」を回答 -
7
:散歩できた場合は「どのくらい歩けたか」を確認するメッセージを Slack に投稿 -
8
:回答した歩数を Google カレンダーに記録
作成手順
Slack アプリケーションの準備
まずは Incoming Webhooks を有効化しておきます。
それから App Manifest に移動すると incoming-webhook
などいくつかの設定はもう反映されているかと思うので、あとは下記の内容で埋めていきます。
display_information:
name: Morning-Walking
description: 朝散歩シヨウゼ
background_color: "#b8c4b4"
features:
bot_user:
display_name: Morning-Walking
always_online: true
oauth_config:
scopes:
bot:
- incoming-webhook
settings:
interactivity:
is_enabled: true
request_url: (ここに GAS へのアクセス URL を貼り付け)
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
request_url
は GAS のデプロイ画面で表示される URL を貼り付けます。デプロイのたびにこの値が変わるので更新を忘れないようにしましょう。
これを忘れているとコードを直したのに挙動が変わらないため混乱します(←やった人)。
Google スプレッドシートの準備
空のシートを準備して適当なフォルダに保存しておきます。
1枚目のシートは散歩するかの記録用。A列にタイムスタンプ、B列に散歩の意思の有無(翌日は散歩を休むぞというときは REST
と入れるようにしています)。
2枚目には上図 4
で Todo として追加したいタスク内容を書き出します。
GAS のタイムゾーン設定
トリガー周りは何もしなくても日本のタイムゾーンに従ってくれるのですが、カレンダーへの登録や Google Todo へのタスク登録時のタイムゾーン設定は何もしないと世界標準時になってしまうようです。
エディタ左側の「プロジェクトの設定」から application.json
を表示するようにしてタイムゾーンを Asia/Tokyo
にしておきます。
{
"timeZone": "Asia/Tokyo",
"dependencies": {
"enabledAdvancedServices": [
{
"userSymbol": "Tasks",
"version": "v1",
"serviceId": "tasks"
}
]
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
}
}
Tasks API の有効化
下記の記事のようにして有効化しておきます。
悩まされたところ:Slack からの Block に応じて処理を分けたい
見直すと、Slack からの送信に対する処理としては、
- 翌朝に散歩をする/しない(
2
) - 散歩をできた/できなかった(
6
)
の4通りと、
- 朝の散歩は何歩だったか(
8
)
を合わせた5通りのに対応すればいいわけです。
ブロックからの送信内容を見て条件分岐させればいいのですが、各 Block ごとに block_id
を設定すればいいことに気がつかず個別のボタンごとに異なる value を設定して対処していました。
2022年2月現在、公式の Block Kit Builder でボタンを追加しても block_id
が表示されないのが罠ですね……。
デフォルトで表示される payload
block_id を含めることができます。
Discussion