(GASとSlack Events APIを利用してご飯のメニューを決めるbot作成② スクリプト(初回メッセージ)の続きです)
だいぶ間が空きましたが、前回は使いたい食材を一品選ぶ選択式メッセージをSlackに送信するコードを作成しました。
今回は選択した食材に応じて対応するメニュー一覧の表示(選んだ食材が人参なら人参が使われているメニューを表示する)、そこから一品選んだ後にメニューと作り方ボタン、作り方のモーダルを表示させるスクリプトを書いていきます。
■ 選択したメニュー+作り方ボタン(上メニューで豚汁を選択した時)
■ 「作り方確認」ボタンを押したときに表示されるモーダル(画像は冷ややっこを選んだ時のもの)
返信メッセージのコードを書くスクリプトファイルの作成
GASは一つのプロジェクト内に複数のファイルを作成できます。
前回作成した選択肢送信用のファイルとは別に、返信用コードを書くスクリプトファイルを「ファイル」>「New」>「スクリプトファイル」から追加します。
新規ファイルにデフォルトで入っているfunctionの名前をmyFunction()→doPost(e)に変更します。そうすることでSlackから送られてくるデータを受け取ることができます。
材料選択メッセージのコードから、WebhockURLやシート名などをプロジェクトのプロパティから読み取るコードをコピペしてきます。(前回の記事で詳細は解説しています)
const INCOMING_WEBHOOK_URL = const INCOMING_WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty("WEBHOOK_URL");
const sheet = SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty("SHEET"))
const token = PropertiesService.getScriptProperties().getProperty("TOKEN");
Slackから送られるデータの分析
こちらのプルダウンメニューで材料が選択されると、SlackからPOSTリクエストが送られますので、それを解析するコードを書きます。
Slackから送られてくるJSONの例
# プルダウンメニュー選択時に送られるJSONの一部
payload={"type":"block_actions",
"user":{"id":(ユーザーID),"username":(ユーザー名),"name":(表示名),"team_id":(チームID)},"api_app_id":(アプリID),"token":(トークン),
"container":{"type":"message","message_ts":(メッセージのタイムスタンプ),"channel_id":(チャンネルID),"is_ephemeral":false},"trigger_id:(トリガーID),"team":{(チームのIDや名前の情報)},
"channel":{(チャンネルのIDや名前の情報)},
"message":{"type":"message","subtype":"bot_message"(メッセージの情報)...
・
・
・
# プルダウンで選ばれた内容は"actions:"以下に入っている
"actions":
{"type":"static_select","action_id":(アクションID),"block_id":"(ブロックID)",
"selected_option":{
"text":{"type":"plain_text","text":(選択されたテキスト),"emoji":true},"value":(選択された値)},
"placeholder":{"type":"plain_text","text":"材料を選んでください(プルダウンメニューのテキスト)","emoji":true},"action_ts":(選択された時のタイムスタンプ)}
var text = e.postData.getDataAsString();
var payload = JSON.parse(decodeURIComponent(text).replace("payload=", ""));
var textで文字列に変換後、payloadでデータの頭についていたpayload=
の文字列を取り除き、JSONをパースして必要なデータを取り出します。(payload=がついたままだと情報が取り出せませんでした)
var type = payload['actions'][0]['type']; //メッセージタイプの読み取り
var reply_url = payload['response_url']; //response_urlの読み取り
まずメッセージタイプによって返信内容を変えるため、パースしたデータからメッセージタイプを読み取ります。この場合はプルダウンメニューであるstatic_select
がJSONのtypeの値に入っています。
次にresponse_urlを読み取ります。これはメッセージに個別に発行される特別なURL(Webhook)で、このURLを使うことでメッセージの書き換えなどが行なえます。リクエストが送れるのは30分に5回まで。
メニュー一覧のメッセージ作成
選ばれた食材をJSON分析で解析したら、食材を選択の次に出てくる、メニュー一覧のメッセージを出すためのコードを書いていきます。
メッセージ削除
プルダウンメニューで食材が選択されたら、一度そのメッセージを削除して別のメッセージに書き換えるため、メッセージ削除のコードを書きます。
//メッセージ削除
function msdelete(payload,token) {
const channel = PropertiesService.getScriptProperties().getProperty("CHANNEL");
var ts = payload['message']['ts']; //タイムスタンプ
var url = "https://slack.com/api/chat.delete?token=" + token + "&channel=" + channel + "&ts=" + ts + "&pretty=1";
UrlFetchApp.fetch(url);
}
メッセージのチャンネルのIDと書くメッセージに与えられたタイムスタンプ(ts)の情報をJSONから取り出し、https://slack.com/api/chat.delete?token=
で送るとメッセージが削除できます。
(余談ですが、constとvarの使い分けがふわっとしているので、間違っているかもしれません。)
スプレッドシートに登録されているメニューを検索
POSTの内容を解析したら、選ばれた食材が使われているメニューをスプレッドシートから検索します。
●メニューシート
・一列目:メニュー名
・二列目以降:使用する食材
//シートに入力されている値を取得
const sheet_mate = sheet.getSheets()[1]; //材料
const sheet_menu = sheet.getSheets()[2]; //メニュー一覧
var text1 = sheet_setting.getRange('B3').getValue();
var menu_mate_o, menu_name_o, menu_mate, menu_name = new Array();
const menu_last_row = sheet_menu.getLastRow();
const menu_last_col = sheet_menu.getLastColumn();
menu_name_o = sheet_menu.getRange(2, 1, menu_last_row - 1).getValues() //メニュー名取得
menu_mate_o = sheet_menu.getRange(2, 2, menu_last_row - 1, menu_last_col).getValues() //材料取得
var menu_mate = menu_mate_o[i].filter(function delete_space(x) {
return !(x === null || x === undefined || x === "");
});
//メニュー検索
if (menu_mate.indexOf(value) < 0) {} else { (略)…
スプレッドシートの入力値を取得して材料の入っている列を検索し、プルダウンメニューで選ばれた材料が入っている列のみピックアップしていきます。
スプレッドシートの中を直接検索しても良いですが、一度配列を作ってから検索したほうが速度が速くなります。
この辺についてはこちらのページが詳しいです。
★スプレッドシートの検索関数を高速化
https://tonari-it.com/gas-spreadsheet-find/#toc6(https://tonari-it.com/gas-spreadsheet-find/#toc6)
上記のコードが何をしているか実際の値で見てみます。
//シートに入力されている値を取得(食材一覧)
[ [ '豚肉', '人参', '玉ねぎ', 'じゃがいも', '', '', '', '' ],
[ '牛肉', 'チンゲン菜', '人参', '舞茸', '太ねぎ', 'しょうが', '', '' ],
[ '鶏むね肉', '', '', '', '', '', '', '' ],
[ '蕪', '油揚げ', '', '', '', '', '', '' ],
[ '豚肉', '人参', '細ねぎ', 'こんにゃく', 'しょうが', '里芋', '大根', '' ],
[ '鶏もも肉', '白菜', '人参', '舞茸', '太ねぎ', 'しょうが', '', '' ],
[ '豚肉', 'しょうが', '', '', '', '', '', '' ],
[ '豆腐', 'しょうが', '', '', '', '', '', '' ],
[ '豚肉', '白菜', '人参', '舞茸', '太ねぎ', 'しょうが', '', '' ],
[ '豚肉', '玉ねぎ', '舞茸', 'しょうが', '', '', '', '' ],
[ '鶏もも肉', '卵', '白米', '太ねぎ', '玉ねぎ', '', '', '' ] ]
スプレッドシートから.getRange(範囲).getValues()
で取り出した食材の配列です。
このままでは扱いにくいので、空白を削除します。
一次元配列にすれば全ての空白が一括で取れますが、メニューごとの材料の配列は保ちたいので、一列ずつ見ていって空白を消していきます。
メニュー名の配列で取り出す列数を設定します。
//シートに入力されている値を取得(メニュー名:menu_name_o)
[ [ 'カレー' ],[ 'チンゲン菜と牛肉の中華風炒め' ], [ 'ローストチキン' ],
[ '蕪の味噌汁' ], [ '豚汁' ], [ '鶏肉と白菜の豆乳炒め' ], [ '豚しゃぶ' ],
[ '冷ややっこ' ],[ '豚肉と白菜の中華風炒め' ], [ '豚肉の生姜焼き' ],[ '親子丼' ] ]
menu_name_o.length = 11
//メニュー名(列)ごとに空白削除を実行
for (let i = 0; i <= menu_name_o.length - 1; i++) { …(略)
一列目のカレーの列の食材から空白を削除すると以下のようになります
//食材から空白削除(一列目)
menu_mate = [ '豚肉', '人参', '玉ねぎ', 'じゃがいも' ]
この一列に材料選択で選ばれた値(value)が入っているか.indexOf(value)
で検索をかけます。
//材料選択で選ばれた値が入ってれば次を実行、入ってなければスキップ
if (menu_mate.indexOf(value) < 0) {} else { …
メニュー一覧のJSON作成
次にif (menu_mate.indexOf(value) < 0) {} else {
の中にメニュー一覧を作成するコードを書いていきます。
食材の列に材料選択で選ばれた値が入っていたら、以下のコードを実行します。
var object = [];
if (menu_mate.indexOf(value) < 0) {} else {
var json = {};
//"これにする!"ボタン 値はメニュー名を設定
var menu_action = {
"type": "button",
"text": {
"type": "plain_text",
"text": "これにする!"
},
"value": menu_name[i]
};
//メニュー名(表示用)
var menu = {
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*" + menu_name[i] + "*" //メニュー名は太字
},
"accessory": menu_action
};
//材料
var material = {
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": "*材料:* " + menu_mate
}]
};
//材料下の枠線
var line = {
"type": "divider"
};
//要素を配列に追加
object.push(menu)
object.push(material)
object.push(line)
var blocks = {
"blocks": object
};
};
}//if (menu_mate.indexOf(value) < 0)の閉じタグ
}//for (let i = 0; i <= menu_name_o.length - 1; i++)の閉じタグ
食材の配列とメニュー名を新しい配列(object
)の中に入れていくコードです。
JSON.stringify
でobject
の中身をテキストに変換するとこのようになります。
//スプレッドシート一列目に入っていたメニュー名と材料メニュー一覧に表示させる
{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*カレー*"},
"accessory":{"type":"button","text":{"type":"plain_text","text":"これにする!"},"value":"カレー"}},
{"type":"context","elements":[{"type":"mrkdwn","text":"*材料:* 豚肉,人参,玉ねぎ,じゃがいも"}]},
{"type":"divider"}]}
このJSONをSlackに送ることでメッセージが表示されます
これを選択された材料が入っているメニューの回数分繰り返せば、画像一枚目のような人参が入っているメニューのみピックアップされたメニュー一覧のメッセージが組み上がります。
ボタンの追加
最後に「材料を選びなおす」と「Cancel」ボタンをobject
に追加します。
var button = { //選びなおしとキャンセルボタン
"type": "actions",
"elements": [{
"type": "button",
"text": {
"type": "plain_text",
"text": "材料を選びなおす",
"emoji": true
},
"value": "re_select"
}, {
"type": "button",
"text": {
"type": "plain_text",
"text": "Cancel",
"emoji": true
},
"style": "danger",
"value": "cancel"
}]
}
object.push(button)
"style": "danger"
を指定するとボタンが赤い色になります。
この二つのボタンを押した後の挙動は後程実装します。
Slackへ送信
JSONができたら、UrlFetchApp.fetch
でSlackへ送信します。
var reply = {
"replace_original": true,
"response_type": "in_channel",
"attachment_type": "default",
"options": "",
"blocks": object
};
var params = {
'method': 'post',
'payload': JSON.stringify(reply)
};
UrlFetchApp.fetch(reply_url, params);
これで食材選択のメッセージがメニュー一覧のメッセージに書き換わります。
選択したメニュー+作り方ボタン+レシピモーダル
続いて「これにする!」ボタンを押したあとに表示されるメッセージと、レシピを表示するモーダルを作成します。
function afterselect(INCOMING_WEBHOOK_URL,sheet_setting, reply_url){
//選択されたメニューの表示
var text2_setting = sheet_setting.getRange('B3').getValue();
//↑メッセージ設定シートに入っている「*メニュー*か、楽しみにしているぞ」を取得
var text2 = text2_setting.replace("*メニュー*", value);
var reply = {
"replace_original": true,
"response_type": "in_channel",
"title": text2,
"text": text2
};
var params = {
'method': 'post',
'Content-type': 'application/json',
'payload': JSON.stringify(reply)
};
//もう一品、作り方確認、終了
var button = [{
"type": "actions",
"elements": [{
"type": "button",
"text": {
"type": "plain_text",
"text": "もう一品選ぶ",
"emoji": true
},
"value": "re_select"
}, {
"type": "button",
"text": {
"type": "plain_text",
"text": value + "の作り方確認",
"emoji": true
},
"value": "cook"
}, {
"type": "button",
"text": {
"type": "plain_text",
"text": "終了",
"emoji": true
},
"style": "danger",
"value": "cancel"
}]
}]
var last_button = {
"attachment_type": "default",
"blocks": button
};
var params_last = {
'method': 'post',
'payload': JSON.stringify(last_button)
};
UrlFetchApp.fetch(reply_url, params);
Utilities.sleep(2000);
UrlFetchApp.fetch(INCOMING_WEBHOOK_URL, params_last);
}
こちらはメニュー選択のメッセージよりシンプルです。
選ばれたメニューを表示させた2秒後、「もう一品選ぶ」「作り方を見る」「キャンセル」の3つのボタンを表示します。
こちらのボタンを押した後の挙動も後程実装しますが、ボタンとメッセージを分けてHTTPリクエストをしている理由は、別メッセージとして扱う事で「もう一品選ぶ」や「キャンセルボタン」が押された時にボタンのみ消すことができるからです。
function cook(sheet,payload,token){
//Slackから送られるPOSTリクエストから必要な情報を取得
var triger = payload['trigger_id']; //トリガーID
var str = payload['actions'][0]['text']['text']; //メニュー名を取得
var value = str.substr(0,str.indexOf("の作り方"))
//スプレッドシートから作り方を配列として取得
const menu_cook = sheet.getSheets()[3];
var cook_last_row = menu_cook.getLastRow();
var cook_last_col = menu_cook.getLastColumn();
var cook_name_dat = menu_cook.getRange(2, 1, cook_last_row - 1).getValues().flat();
var dat = menu_cook.getRange(2, 1, cook_last_row - 1, cook_last_col - 1).getValues();
var cook_row = cook_name_dat.indexOf(value)
//作り方のモーダルウインドウを作成
var blocks = [{
"type": "image",
"image_url": dat[cook_row][1],
"alt_text": value
}]
for (let i = 2; i < dat[cook_row].length - 1; i++) {
var section = {}
var n = i - 1
section.type = "section"
section.text = {
"type": "mrkdwn",
"text": "*" + n + "*\n" + dat[cook_row][i]
}
blocks.push(section)
}
var cook = {
"type": "modal",
"title": {
"type": "plain_text",
"text": value + "の作り方",
"emoji": true
},
"close": {
"type": "plain_text",
"text": "Cancel",
"emoji": true
},
"blocks": blocks
}
//HTTPリクエストで送信
var cook_show = {
'method': 'post',
'headers': {
'contentType': 'application/json;charset=utf-8',
'Authorization': 'Bearer ' + token
},
'payload': {
'trigger_id': triger,
'view': JSON.stringify(cook)
}
};
UrlFetchApp.fetch("https://slack.com/api/views.open", cook_show);
}
普通のメッセージとは違い、Slackのモーダルウインドウはそれを表示させるためのtrigger_id
が必要です。そのため「作り方を見る」ボタンを押したのちSlackから送られてきたPOSTリクエストからtrigger_id
を読み取ります。
メッセージの内容とトリガーIDを含めたHTTPリクエストを送信すれば、ボタンを押したときにモーダルウインドウが表示されます。
なお、補足として、こちらのモーダルウインドウを表示させるにはトリガーとなるボタンなどが必要です。(trigger_id
を発行するため)ただHTTP送信しただけではエラーで表示されません。
各ボタンを押したときの挙動を実装
最後に、表示させるメッセージの分岐を実装します。
if (type == "static_select") {//材料選択かどうかを判断
var value = payload["actions"][0]["selected_option"]["value"];
select(value, sheet, sheet_setting, reply_url,sheetName);
} else {
var value = payload['actions'][0]['value'];
if (value == "re_select") { //もう一品選ぶ
msdelete(payload,token);
bot();
} else if (value == "cook") { //作り方ボタン
cook(sheet,payload,token);
} else if (value == "cancel") { //キャンセルボタン
msdelete(payload,token);
} else {
//メニュー選択後のメッセージ表示
afterselect(INCOMING_WEBHOOK_URL,sheet_setting, reply_url);
}
}
まず受け取ったのが材料選択のリクエストかどうかを判断し、そうであればselect(メニュー選択)を実行します。
そうでないならメニュー選択後のメッセージ表示、いずれかのメニューでキャンセルボタンが押されたらメッセージの削除を実行します。
開発中につっかかった点:返信機能実装時にデバックがし辛い
知識不足だったり色々なところで突っかかったのですが、非常に苦労したのがこの点です。
GASというかdoPost関数の仕様上、データを受け取ることではじめて動作するため、デバッグがし辛くなっています。
さらにGASは以下の「Apps Script ダッシュボード」で実行のログを見れるようになっていますが、外部アプリから動作した場合は実行日時のと実行時間の記録だけで、console.log
などを入れてもデータが残らない仕様になっています。
つまりSlackからどのような内容のデータを受け取っているのかが出力しにくいのです。
この対策の一つとして、スプレッドシートに処理内容を書き出すことが挙げられます。
var id = "シートID";
var spreadSheet = SpreadsheetApp.openById(id);
var sheetName = "sistem_log";
var r_txt = "人参"
spreadSheet.getSheetByName(sheetName).appendRow(
[new Date(),"value", r_txt]
);
spreadSheet.getSheetByName(sheetName).appendRow(
[new Date(),"url", url]
);
new Date()
で実行日を出力、コンマで区切ることで2行目、3行目と出力できます。
.appendRow
(スプレッドシートの最後の行に追加)は複数行で書き出せないため、一つ一つ指定してます。
(複数行書き出す方法もありますが、書き出したいものをしょっちゅう変更したりするならこちらの方が良いと思います。複数行書き出す場合はこちらのサイトの「配列データを最終行に一発書込」をご覧ください。)
便利なツール
最後に、Slackの開発に便利なプラグインをご紹介します。
Slack Developer Tools
Slackのメッセージの中身のJSONを見れるプラグインです。
プラグインを追加するとメッセージのメニューの中に表示され、クリックするとモーダルウインドウでJSONが表示されます。
このモーダルウインドウの下の方にはBlock Kit Bilderへのリンクもあり、メッセージを組むためのJSONも作成できます。
最後に
ここまで読んで下さり、ありがとうございました。整理しきれてないところが山とありますが、一つでも参考になった点がありましたら幸いです。