毎日の体温をエクセルシートの手入力するのがたるいので、自動化をしてみました。
ハード構成、サービスとの連携実装をしていきます。
#目次
1. はじめに
2. 構成検討
3. 体温計作製
4. gas
5. line連携
6. excel連携
7. おわりに
1. はじめに
最近コロナ禍において、体温を記録するニーズが急激に高まりました。
指定のエクセルシートに自宅で測定してきた体温を手入力する、というケースにおいて、
「出社前の30分前に測定した自分の体温を忘れる」
「指定のエクセルシートを開くのがめんどくさい」
となってしまうため、ついつい記録漏れが発生してしまいます。
体温測定は仕方ないものとして、入力を自動的にできる方法を考えることにしました。
すなわち、要件は
「測定した体温を指定のエクセルシートへ人の手を介さずに入力する。」
とします。
2. 構成検討
構成の検討をしていきます。
● 体温測定
短時間で測定できることが望ましいので、非接触の赤外温度計を用いることにします。
MLX90164というサーモパイルユニットを用います。
● マイコン
測定した体温をディスプレイで表示し、wifiで送信するしたいので、
M5StickCを使います。
● クラウドサービス
GASを使って、Google Spreadsheetに体温と日付データを入力することにします。
入力忘れの通知とLineから体温を手動入力できるように、Line devolopersとGASの連携もすることにします。
● エクセルシートへのデータ入力
winPCから入力したいエクセルファイルをタスクスケジューラで定期実行し、
起動毎にパワークエリでSpreadSheet上のデータを取りに行くような形にします。
データをエクセル側に転記し、日付参照して所望のセルに体温が反映されるようにします。
業務のジャマにならないように、昼休みの時間をタスクスケジューラの起動時間にしておきます。
まとめると下記のような構成図です。
クラウド側は無課金の範疇内でやります。
3. 体温計作製
下記のようなディスプレイを作りました。
M5stickCは正面メインのボタン(M5記載)とサイドのボタンの2つがあります。
・M5ボタンで体温の測定開始する。
・体温測定後、サイドボタンでSpreadSheetへデータ送信
・複数の人が使用することを想定し、未入力数秒で待機状態に遷移し、
待機状態でサイドボタンを押すとID切り替えをする。
とするような動きにします。
idの切替が視覚的にわかりやすいように、画像id固有の画像表示を入れました。
idが変わると画像が切り替わるようにします。
MLX90614を両面ユニバーサル基板に実装し、簡易HATのような形にします。
純正のHATを使ったほうが見た目スッキリですが、高いのでケチって基板実装しました。
作りは荒いけど機能は変わらないのできにしない。
また、MLX90614は入射角が50度くらいと広いので、外乱の影響を意図せず含めてしまいがちです。
指向性のある仕様のセンサを使ってもいいですが少し高くなるので、今回はタピオカストローにアルミホイルを巻き、体表にセンサをくっつける形で測定するような使い方にすることにしました。
接着剤で強引にくっつけてます。
M5Stickの動作はAruduinoで記述します。
下記のような感じです。
GASのデプロイID入力がありますが、それは次項にて。
ソースコードを表示(折りたたみ)
#include "FS.h"
#include "SPIFFS.h"
#include "M5StickC.h"
#include "M5Display.h"
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Adafruit_MLX90614.h>
#include "esp_deep_sleep.h"
#include "esp_system.h"
#include "time.h" //時刻取得用
#include <Wire.h>
#include "AXP192.h"//m5の電源管理ic
//サーモパイル設定
Adafruit_MLX90614 mlx = Adafruit_MLX90614();
//ピン設定
int buttun_A = 37;
int buttun_B = 39;
int led = 10;
int id=0;
int scl=26;
int sda=0;
//ボタン割付
int curr_A;
int last_A;
int curr_B;
int last_B;
bool flg;
//wifi設定
const char* ssid; //無線ルーターのssidを入力
const char* password; //無線ルーターのパスワードを入力
char* ssid1 = "[wifiのssid入力]"; //無線ルーターのssidを入力
char* pass1 = "[wifiのpass入力]"; //無線ルーターのパスワードを入力
char* ssid2 = "[予備用wifiのssid入力]"; //無線ルーターのssidを入力
char* pass2 = "[予備用wifiのpass入力]"; //無線ルーターのパスワードを入力
//ボタン押しながら起動で予備用wifiモードで起動するようにしておく。
//GASの情報
const char* server = "script.google.com";
const char* key ="[GASのデプロイしたidを入力]";
String url; //url入力用の変数
String adress = "https://script.google.com/macros/s/"; //googlescript web appのurl
//変数設定用
double temp;//体温
double ambient;//外気温
double offset;//実測からの補正値
double V_bat;//バッテリ電圧
double I_bat;//バッテリ電流
String id_name;
long count_timer;
long curr_time;//現在時刻格納用
long past_time;//ボタン押されてからの時間カウンタ
long sleep_time =30*1000; //スリープ用タイマ msec
long temp_time;
long buf_time1;
long count;//リセット用
bool send_flg;
int wifi_mode=0;//初期設定 0で家のwifi,1でスマホテザリングモード。
//bmp転送用 https://wak-tech.com/archives/1799
uint16_t read16(fs::File &f) {
uint16_t result;
((uint8_t *)&result)[0] = f.read(); // LSB
((uint8_t *)&result)[1] = f.read(); // MSB
return result;
}
uint32_t read32(fs::File &f) {
uint32_t result;
((uint8_t *)&result)[0] = f.read(); // LSB
((uint8_t *)&result)[1] = f.read();
((uint8_t *)&result)[2] = f.read();
((uint8_t *)&result)[3] = f.read(); // MSB
return result;
}
//bmpを表示する関数
void drawBmpFile(fs::FS &fs, const char *path, uint16_t x, uint16_t y) {
if ((x >= 160) || (y >= 80)) return;
// Open requested file on SD card
File bmpFS = fs.open(path, "r");
if (!bmpFS) {
Serial.print("File not found");
return;
}
uint32_t seekOffset;
uint16_t w, h, row, col;
uint8_t r, g, b;
uint32_t startTime = millis();
if (read16(bmpFS) == 0x4D42) {
read32(bmpFS);
read32(bmpFS);
seekOffset = read32(bmpFS);
read32(bmpFS);
w = read32(bmpFS);
h = read32(bmpFS);
if ((read16(bmpFS) == 1) && (read16(bmpFS) == 24) && (read32(bmpFS) == 0)) {
y += h - 1;
M5.Lcd.setSwapBytes(true);
bmpFS.seek(seekOffset);
uint16_t padding = (4 - ((w * 3) & 3)) & 3;
uint8_t lineBuffer[w * 3 + padding];
for (row = 0; row < h; row++) {
bmpFS.read(lineBuffer, sizeof(lineBuffer));
uint8_t* bptr = lineBuffer;
uint16_t* tptr = (uint16_t*)lineBuffer;
// Convert 24 to 16 bit colours
for (col = 0; col < w; col++) {
b = *bptr++;
g = *bptr++;
r = *bptr++;
*tptr++ = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
// Push the pixel row to screen, pushImage will crop the line if needed
// y is decremented as the BMP image is drawn bottom up
M5.Lcd.pushImage(x, y--, w, 1, (uint16_t*)lineBuffer);
}
Serial.print("Loaded in "); Serial.print(millis() - startTime);
Serial.println(" ms");
}
else Serial.println("BMP format not recognized.");
}
bmpFS.close();
}
//温度送信する関数
void send_temp(){
Serial.print("name:");
Serial.println(id_name);
Serial.print(" temp:");
Serial.println(temp);
Serial.print(" ambient:");
Serial.println(ambient);
Serial.print(" offset:");
Serial.println(offset);
WiFiClientSecure sslclient;
//urlの末尾に測定値を加筆
url = adress;
url += key;
url += "/exec?";
url += "&1_cell=";
url += id_name;
url += "&2_cell=";
url += temp;
url += "&3_cell=";
url += ambient;
url += "&4_cell=";
url += offset;
// サーバーにアクセス
Serial.println("サーバーに接続中...");
//データの送信エラー時
if (!sslclient.connect(server, 443)) {
Serial.println("接続に失敗しました");
Serial.println("");//改行
return;
}
Serial.println("サーバーに接続しました");
sslclient.println("GET " + url);
delay(3000);
sslclient.stop();
M5.Lcd.setCursor(5,60);
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(WHITE, BLACK);
M5.Lcd.print("Data uploaded!");
Serial.println("Send to SpreadSheet");
Serial.println(url);
delay(2000);
}
//wifi接続
void connectWiFi(){
Serial.print("ssid:");
Serial.print(ssid);
Serial.println(" に接続します。");
WiFi.begin(ssid, password);
Serial.print("WiFiに接続中");
count=0;
while(WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(500);
count++;
//Serial.println(count);
if(count>20){//30count以内に接続できない場合はリセット
esp_restart();
}
}
Serial.println("接続しました。");
//IPアドレスの表示
Serial.print("IPアドレス:");
Serial.println(WiFi.localIP());
}
//基本画面表示
void disp(){
M5.Lcd.fillRect(1,1,90,60,BLACK);
M5.Lcd.fillRect(90,1,50,50,BLACK);//x 90+50, y 1+50を消去
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(WHITE, BLACK);//白文字 黒背景
if(id==1){
M5.Lcd.setCursor(20,10);
M5.Lcd.print("[name1]");
id_name="[name1]";
drawBmpFile(SPIFFS, "/tokage.bmp", 90, 15);//bmpを読み込んで表示する関数
}
else if(id==2){
M5.Lcd.setCursor(20,10);
M5.Lcd.print("[name2]");
id_name="[name2]";
drawBmpFile(SPIFFS, "/neko.bmp", 90, 15);//bmpを読み込んで表示する関数
}
else if(id==3){
M5.Lcd.setCursor(20,10);
M5.Lcd.print("[name3]");
id_name="[name3]";
drawBmpFile(SPIFFS, "/tonkatsu.bmp", 90, 15);//bmpを読み込んで表示する関数
}
else{
M5.Lcd.setCursor(10,10);
M5.Lcd.print("Push side");
M5.Lcd.setCursor(10,20);
M5.Lcd.print("button");
drawBmpFile(SPIFFS, "/tapi.bmp", 90, 15);//bmpを読み込んで表示する関数
if(wifi_mode==1){
M5.Lcd.setCursor(10,30);
M5.Lcd.print("wifi:phone");
}
else{
M5.Lcd.setCursor(10,30);
M5.Lcd.print("wifi:home");
}
}
disp_bat();//バッテリ電圧表示
}
//温度計測&表示
void disp_temp(){
double sum_body=0.0;
double sum_amb=0.0;
for(int i=0;i<30;i++){//10回取得して平均化
sum_body += mlx.readObjectTempC();
sum_amb += mlx.readAmbientTempC();
delay(10);
}
temp=sum_body/30.0;//平均化
ambient=sum_amb/30.0;//平均化
offset=ambient*(-0.159)+6.471;//外気温度補正
temp=temp+offset;
Serial.print(temp);
Serial.print(" *C ");
Serial.print(ambient);
Serial.print(" *C ");
Serial.println(offset);
M5.Lcd.setCursor(5,30);
M5.Lcd.setTextSize(2);
M5.Lcd.setTextColor(RED, WHITE);
M5.Lcd.print(temp);
M5.Lcd.print(" C");
}
void send_ready(){
disp_temp();
send_flg=0;
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(WHITE, BLACK);//白文字 黒背景
M5.Lcd.fillRect(5,60,85,30,BLACK);//5+85, 60+30を塗潰し
M5.Lcd.setCursor(5,60);
M5.Lcd.print("To upload,");
M5.Lcd.setCursor(5,70);
M5.Lcd.print("push side");
temp_time=0;
buf_time1=millis();//送信待ちのタイマ
while (temp_time<5000){//10秒間送信待ち
temp_time=millis()-buf_time1;
if(digitalRead(buttun_B)==0 && send_flg==0){//flg 0状態でボタン押されたら1回だけフラグたててBreak
send_flg==1;
M5.Lcd.fillRect(5,60,85,30,BLACK);//5+65, 60+30を塗潰し
Serial.println("送信フラグON");
M5.Lcd.setCursor(5,60);
M5.Lcd.print("uploading...");
connectWiFi();
send_temp();
WiFi.mode(WIFI_OFF);//完了したらwifi切る
Serial.println("wifi切断しました。");
break;
}
else if(digitalRead(buttun_A)==0){
M5.Lcd.setTextColor(WHITE, BLACK);//白文字 黒背景
M5.Lcd.fillRect(5,60,85,30,BLACK);//5+85, 60+30を塗潰し
send_ready();
break;
}
}
if(send_flg==0){
M5.Lcd.setTextColor(WHITE, BLACK);//白文字 黒背景
M5.Lcd.fillRect(5,60,85,30,BLACK);//5+85, 60+30を塗潰し
}
}
void ch_id(){
id++;
if(id>3){
id=1;//idリセット
}
}
//バッテリ電圧を表示
void disp_bat(){
V_bat=M5.Axp.GetBatVoltage();
I_bat=M5.Axp.GetBatCurrent();
M5.Lcd.setTextSize(1);
M5.Lcd.setTextColor(WHITE, BLACK);//白文字 黒背景
M5.Lcd.fillRect(1,90,60,10,BLACK);//5+85, 60+30を塗潰し
M5.Lcd.setCursor(90,1);
M5.Lcd.print("bat:");
M5.Lcd.print(V_bat);
M5.Lcd.print("V");
}
void setup() {
//ボタンの初期設定
curr_A=1;
last_A=1;
curr_B=1;
curr_B=1;
//wifiモード ボタン押しっぱ起動でスマホ/家wifiと切替
long count=millis();
while(millis()-count<3000){
if(digitalRead(buttun_B)==0){
wifi_mode=1;
}
}
if(wifi_mode==0){
ssid = ssid1;
password = pass1;
}
else if(wifi_mode==1){
ssid = ssid2;
password = pass2;
}
//deepsleep設定
esp_sleep_enable_ext0_wakeup(GPIO_NUM_37,0);//復帰設定 0で復帰
//M5初期設定
M5.begin();
setCpuFrequencyMhz(80);//wifiは最小でも80MHz必要
Wire.begin(sda, scl);
mlx.begin();
M5.Axp.ScreenBreath(10);// 7-12で明るさ設定
M5.Lcd.setRotation(3);// 横向き
Serial.begin(9600);
pinMode(buttun_A, INPUT);
pinMode(buttun_B, INPUT);
//bmpファイル置き場のSPIFFSマウント確認
if(!SPIFFS.begin(true)){
Serial.println("SPIFFS Mount Failed");
return;
}
//スリープタイマカウントを初期化
curr_time=millis();
past_time=curr_time;//スリープ用のタイマを初期化
disp_bat();//バッテリ電圧表示
}
void loop() {
mlx.readObjectTempC();//暖気用常時稼働
mlx.readAmbientTempC();//暖気用常時稼働
curr_A=digitalRead(buttun_A);
curr_B=digitalRead(buttun_B);
curr_time=millis();//現在時刻更新
count_timer=curr_time-past_time;
if(count_timer>sleep_time){ // スリープモード移行(ボタンによる復帰のため復帰タイマー時間0)
Serial.println("寝ます");
delay(1000);
M5.Axp.ScreenBreath(0);//ディスプレイを切る
esp_deep_sleep_start();
delay(1000);
Serial.print("ここは表示されない");
}
else{
if(curr_B != last_B){//ボタン押して1回だけ実行する
if(curr_B==0){//ボタン押している間
ch_id();
}
else{//ボタン押されてないタイミング
disp();
}
last_B=curr_B;
past_time=millis();
}
if(curr_A != last_A){//ボタン押して1回だけ実行する
if(curr_A==0){//ボタン押している間
}
else{//ボタンを離したタイミング
//timerWrite(timer, 0); //WDTをリセット
send_ready();
}
last_A=curr_A;
past_time=millis();
}
}
}
4. gas
送られてきたデータをGASでSpreadsheetに書き込むようにします。
放射温度計は外部温度に依存するので、実測温度に加えて外部温度も記録しておきます。
「all」のシートにまず一旦全てのデータを集約するようにして、各名前の個別シートを別で起こしてフィルタする形にしました。
実際にこのあと使っていくのは個別シートです。
「=filter(all!A2:C2,all!B2="[名前1]")」などでallシートから名前でフィルタして持ってくるようにしておきます。
マッチする名前がない日付ところはN/A表示されますが、特に影響無いのでそのままで。
好みでヒストグラムとタイムトレンドを表示しておきます。
GASのスクリプトは下記です。
ソースコードを表示(折りたたみ)
var id = '[SpreadsheetのIDを入力]';//スプレッドシートの指定
function doGet(e) {
console.log("start func_doget(e)")
console.log("check e:",e);
// 今日の日付
var d =new Date();
var date_t = Utilities.formatDate(d, 'Asia/Tokyo','yyyy/MM/dd');
console.log("date_t:",date_t);
var rowData = [];
rowData[0] = new Date(); //タイムスタンプ入力
for (var param in e.parameter) {
var value = e.parameter[param]; //送信されたデータを取得
rowData[parseInt(param)] = value; //配列に入れ込み
}
var name = rowData[1];
console.log("name:",name);
//書き込み
var sheet = SpreadsheetApp.openById(id).getSheetByName('all');
var newRow = sheet.getLastRow() + 1; // 次の行に入力する
var newRange = sheet.getRange(newRow, 1, 1, rowData.length);
newRange.setValues([rowData]); //ここでシートに書き込み
var result = 'Ok'
return ContentService.createTextOutput(param);
}
スクリプトをwebアプリとしてデプロイしてIDを取得します。
取得したデプロイIDを前半のM5stickCコードへ転記し書き込みします。
忘れないように注意。
5. line連携
Lineと連携して、
・体温入力忘れ時の通知
・Lineからの体温の入力
をLineDeveloperのMessageAPIを使ってやります。
GASで時刻でトリガを設定し。Spreadsheet上を検索し、過去数日間で未入力が無いかチェックし、
未入力がある場合はLineで通知を行います。
で、下記のように日付指定して体温を入力したら、Spreadsheetに数値を書き込むようにします。
入力ミス時用に、予期しない形での入力に対してはエラーを返すようにして、
Spreadsheetを保護しておきます。
ソースコードを表示(折りたたみ)
var id = '[SpreadsheetのID]';//スプレッドシートの指定
const ACCESS_TOKEN = '[Lineのアクセストークン]';
const line_bot="[LinebotのID]"
//実行関数
// Lineからの入力チェック
function doPost(e) {
console.log("start func_doPost(e)")
console.log("check e:",e);
// WebHookで受信した応答用Token
var replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
// ユーザーのメッセージを取得
var userMessage = JSON.parse(e.postData.contents).events[0].message.text;
console.log("userMessage:",userMessage);
console.log(Object.prototype.toString.call(userMessage));
if(isFinite(userMessage) || userMessage.match("/")){
console.log("Sheetに体温入力");
setData(userMessage);
}
else{
console.log("Lineから想定外の入力");
push_line("入力エラーです。本日分の場合[xx.x], 日付指定の場合は [*/* xx.x]と入力してね。");
return;
}
return;
}
//行検索する関数
function findRow(sheet, date) {
var searchDate = Utilities.formatDate(new Date(date), 'Asia/Tokyo','yyyy-MM-dd');
var values = sheet.getDataRange().getValues();
for (var i = values.length - 1; i > 0; i--) {
if(values[i][0]=="#N/A"){
continue;
}
var dataDate = Utilities.formatDate(values[i][0], 'Asia/Tokyo','yyyy-MM-dd');
if (dataDate.match(searchDate)) {
console.log("dataDate:",dataDate,"::serchDate:",searchDate);
return i + 1;
}
}
return false;
}
// Line msgをSheetへ入力
function setData(msg){
console.log("start func_setData")
console.log("msg:",msg);
console.log(Object.prototype.toString.call(msg))
var d =new Date();
var date_t = Utilities.formatDate(d, 'Asia/Tokyo','yyyy/MM/dd');
var sheet = SpreadsheetApp.openById(id).getSheetByName('all');
var lastRow = sheet.getLastRow(); //最終行取得
if(msg.match("/")){
var T=""
for(var i=0;i<msg.length;i++){
if(msg[i].match(" ")){
var y=Utilities.formatDate(d, 'Asia/Tokyo','yyyy');
date_t=y+"/"+T;
var S=""
for(var j=i;j<msg.length;j++){
S=S+msg[j];
}
msg=S;
break;
}
T=T+msg[i];
}
console.log("date_t:",date_t)
console.log("msg:",msg)
}
var cells = sheet.getRange(lastRow+1, 1, 1, 3);
var data=[date_t,"[name1]",msg.split(/\n/)];
console.log("data",data);
// getRange([指定行],[指定列],[行範囲数],[列範囲数])
cells.setValues([data]);
return;
}
// https://www.pnkts.net/2018/06/03/line-messaging-api/
//Lineへメッセージを送る関数
function push_line(str){
var url = "https://api.line.me/v2/bot/message/push";
var headers = {
"Content-Type" : "application/json; charset=UTF-8",
'Authorization': 'Bearer ' + ACCESS_TOKEN,
};
var postData = {
"to" : line_bot,
"messages" : [
{
'type':'text',
'text':str,
}
]
};
var options = {
"method" : "post",
"headers" : headers,
"payload" : JSON.stringify(postData)
};
return UrlFetchApp.fetch(url, options);
}
定期実行の関数です。
name1 シート上で当日から7日前までさかのぼって、入力があるかどうかを検索します。
値がなかった日付について、Lineで通知メッセージをするようにします。
7日間分ちゃんと体温が入力されてる場合は、OK的なメッセージを返します。
ソースコードを表示(折りたたみ)
// GAS定期実行
// https://qiita.com/rf_p/items/267a8d9daa8c9f1ef027
function TimeTriger(){
console.log("start func_TimeTriger()")
var d =new Date();
var date_t = Utilities.formatDate(d, 'Asia/Tokyo','yyyy/MM/dd');
console.log("date_t:",date_t);
console.log("入力状況の検索を開始");
var sheet = SpreadsheetApp.openById(id).getSheetByName('[name1]');
// getRange([指定行],[指定列],[行範囲数],[列範囲数])
var flg=true;
var date_emp=[];
// 1週間前までで未入力を探す
for(let i = 7; i >= 0; i--){
var d_pre =new Date();
var day=d.getDate();
d_pre.setDate(day-i);
var date_pre = Utilities.formatDate(d_pre, 'Asia/Tokyo','yyyy/MM/dd');
var row_find = findRow(sheet, date_pre);
console.log("date_pre:",date_pre,":: row_find:",row_find)
if(row_find==false){
date_emp.push(date_pre);
flg=false;
}
}
console.log("date_emp:",date_emp);
// 未入力日付をlineにPush
if(flg==false){
var blank_l=date_emp.length
var txt="";
for(var i=0;i<blank_l;i++){
txt=txt+date_emp[i]+"\n"
}
txt=txt+"上記体温入力がありません。";
console.log(txt);
push_line(txt);
return;
}
else{
// その日の体温を返す
// var row_find=findRow(sheet, date_t);
// var val=sheet.getRange(row_find, 3, 1, 1).getValues();
// console.log("val:",val);
// var txt= date_t+"の体温は"+val+"℃です。"
var txt="体温入力OK"
push_line(txt)
}
}
ついでにLineのMessageAPIの画面もスクショします。
Webhoook URLにデプロイしたGASのIDをコピペしておきます。
6. excel連携
これでSpreadsheet上にデータをいい感じに保存できる環境ができたので、あとはエクセルからデータを取りに行きます。
パワークエリでwebアクセスして、持ってくるだけです。
下記を参考にさせてもらいました。
これで所望のSheet, セルのデータを下記のように引っ張ってこれます。
今回はこれを定期的に更新したいです。
なのでまず「ブックの接続」からエクセルを起動したら自動で更新されるようにします。
で、windowsタスクスケジューラでこのエクセルを毎日12:30に実行するようにします。
毎度エクセルが立ち上がるので、上書き保存が必要ですが入力確認も兼ねるということでそのままにしておきます。
自動で閉じるのであれば、エクセル側にマクロでも仕込んでおけばいいのかな。
あとは入力シートに合わせて、日付などをキーにして体温を持っていきます。
lookupなり、index&matchなりなんなりで、うまいことやります。
会社のシートを晒すのもちょっとアレなので、この辺までにしておきます。
7. おわりに
一気に書いたらだいぶ長くなってしまいました。。。
やりたいことはとりあえず実装できました。
LineBotとか無料範囲でも結構やれるんだなーと気づけました。
メッセージを遅れるのは何かと便利な気がするので、他のサービスとかデータと連携して、
色々賢いBotに育ててあげれたらいいな。