🔥

Googleフォームの提出忘れを毎日リマインドしてくれるLINEbotを作成する

2023/09/01に公開

はじめに

記念すべき(?) Zenn第一稿です。
タイトルの通りのものを昔作成したので、初学者向けのそれの作り方の記事です。
このサービス作成を通して、システム設計や実装の際の考え方などの一般的な話も詳しく説明できればと思います。なんの専門知識もないただの学生なので、あくまで「こう作ると良いよ」ではなく「自分はこう作っています」という話です。

作成経緯

僕自身あまり前置き長い記事好きじゃないんですが、自分語り交える方が書くモチベが続く気がするので許してください。

学部時代の4年間体育会に所属していたんですが、1年の冬コロナが流行り始め、部活が一時期活動停止になりました。その状態が夏まで続き、ようやく練習再開できるとのことだったのですが、体育会本部から条件の通達が...

「毎日Googleフォームでその日の体温を提出すること。1日でも忘れたら、14日間練習に参加できない」

いや、厳しすぎる..
クラスター出した大学が世間で叩かれてたりして敏感な時期だったから気持ちもわかるけど。

まあとは言っても半年ぶりの念願の練習再開。流石に誰も忘れないか!と思ったのも束の間...

でました、大量の活動停止者(自分含む)
午前練終わって帰って疲れて寝たら24時過ぎてて、、 みたいなこともあるし、1日忘れるって普通にやっちゃうんですよね(ダメ)。なんだかんだ油断した頃に部員の誰かが忘れ、結局部員全員(30人くらい)が全員練習に揃う日は最初の数日以外一度もありませんでした。

みかねた当時の主将が毎晩未提出者をみてグループLINEで教えてくれるようになったんですが、そんな部員の尻拭いの雑務を主将に忘れるわけにもいかないし。ってことで僕が動きました。

目標は、再び部員全員で練習をすること(涙目)

技術選定

アプリ

まずはどういうシステムにするかです。パッと思いついたのはそういうのを作りやすそうなSlackbot。けど部のSlackがあるわけでもなく、わざわざ作るのも手間なので却下。
部員全員が元々入れてて、リマインドシステムとして最適なアプリ... もうLINEしかないですね。という感じで LINEbot を作ることにしました。

LINEbotはグループにもメッセージを送れるので、部のグループにbotを招待してやってもよかったんですが、

  • レート制限の引っかかりやすさは変わらない (参考)
  • 他の大事な連絡が流れてしまう

といった理由から、個別にメッセージを送ることにしました。

プラットフォーム・言語

当時かける言語はPythonくらいだったので、Pythonでプログラム書いて毎日手動で実行することで実現しようと思ったけど、実行し忘れたら意味ないからダメ。次に 「無料で毎日決まった時間にスクリプトを実行する」 ことができるサーバーにプログラムを書いて置こうと思って探したんですが、自分が知らないだけかもだけど意外に少ないですよね。有料とか静的ページを置くサーバならたくさんあるんですが。
てことで悩んだ挙句、一ついいのがありました。
Google Apps Script
いわゆる GAS です。Googleフォームやスプレッドシートとも連携がとりやすく、尚且つトリガーを設定することで決まった時間にプログラムを実行できる。まさにピッタリですね。
あとはGASとLINEbotを連携できるかどうかですが、調べたら記事が結構出てきたので普通にできそうです。

こんな考えを経て、 LINEbot × GAS でサービスを開発することに決めました。

いざ、LINEbot 作成

システム概要

作るシステムはざっくりこんな感じです。

システム概要

体温提出管理のスプレッドシートは、実際には下の図のような、名前や最終提出日、提出していればその日提出した体温が載っています。その日に出してなければD列より右は空欄です。

体温提出スプレッドシート

システムがやるべきことは大きく分けて2つで、毎日決まった時間に

  • GASからスプレッドシートを読み込み、体温未提出者の名前をピックアップする。
  • 体温未提出者に対してLINEでリマインドを送る

という感じです。

プラスで追加機能として、

  • 希望者に対して毎朝GoogleフォームのURLを送信する機能

をつけたいと思います。
これによって、そもそも夜まで提出するのを忘れるといったことも減るし、その日提出したかどうかを未読かどうかで一目で判断できるようになります。

名前とLINEアカウントを紐づけ

LINEのUserIDと、提出管理用スプレッドシートに書かれてる名前を結びつける必要があります。
本名が分かったところで、APIからメッセージは送れません。

ここで、LINEbotと連携したGASでは、関数名をdoPost(e)とすることで、友達登録時やメッセージ受信時に、その情報を引数に取る関数を実行することができます。時間ではなく、LINEメッセージの受信をトリガーにするイメージですね。この時受け取る情報には、受信したメッセージだけでなく、タイムスタンプや送信者のUserIDなど様々なデータが含まれます。

そこで、今回のシステムは、部員自身にメッセージで自分の名前を送ってもらうといった方法で、IDと名前の紐付けを行いたいと思います。

紐付けした情報はどうやって保存するのが良いでしょうか?
普通のサービスなら真っ先に思いつくのはデータベースですが、システムの構成や実装が複雑になるのも嫌だし、GASと相性の良いデータの保存先って...
もう一択ですよね。スプレッドシートでしょう。

ということで、紐付け手順としては

  1. 友達追加されたら「名前を送ってください」と送信する。
  2. メッセージが送られてきたらdoPost関数内でuserIDと送られてきたメッセージを抽出し、LINE管理用のスプレッドシートに書き込む

という感じです。

しかし、ここで注意しないといけないことがあります。それは、ユーザが正しい名前を送るとは限らないということです。
さて、ここでいう「正しい名前」とは、「提出管理用シート」に載っている名前のことです。このシートとLINE管理用シートで名前が異なると、情報が正しく結合されず、未提出者のUserIDが取得できません。
中には「自分の名前を間違えて送ることなんてまずない」と思う人もいるかもしれませんが、誤字脱字以外にも苗字と名前の間の空白の有無や旧漢字など、意味的に合っていても文字列の比較としては間違っていることなんてザラにあります。提出管理用シートにIDみたいなのがあれば良かったんですが..

ということで、ただ受け取ったメッセージをシート②に書き込むのではなく、シート①にその名前が存在するかを確認する必要がありそうです。

よって、より適切な紐付け手順はこうです。

  1. 友達追加されたら「名前を送ってください」と送信する。
  2. メッセージが送られてきたら、提出管理用シートにその名前が存在するかを確認する
  3. 存在すれば、LINE管理用スプレッドシートに名前とuserIDを書き込む

ちなみに実際には、LINE管理用シートの中には

  • 夜に未提出者だった時にリマインドを送るユーザのテーブル(=登録者全員)
  • 朝にGoogleフォームのURLを送るユーザのテーブル(希望者)

の2つがあります。テーブルの形式は一緒です。

LINEbotアカウント発行とGASとの連携

さて、長々とシステムの設計について話しましたが、そろそろbotの作成と実装に移っていきます。

まずはbot用のアカウント発行しGASと連携する部分についてですが、これは「GAS LINEbot」などで調べると大変分かりやすい記事が大量に転がっているので、わざわざこの記事で詳しく解説しません。
が、簡単な手順としては、

  1. LINE DevelopersのMessaging APIのページからアカウントを発行し、GASで使うアクセストークンを取得
  2. GASでそのアクセストークンを使って、doPostのサンプルコードを実装
  3. GASで”ウェブアプリ”としてデプロイし、WebアプリのURLをLINE Messaging APIのWebhookに登録
  4. LINE Messaging APIで、Webhookの利用をON, 応答メッセージをOFFにする
  5. 実際に友達追加して適当なメッセージを送信し、コード通りの返答が来るかを確認

というような流れになります。

参考までに、このあたりの記事が大変分かりやすいです。(他力本願)
https://auto-worker.com/blog/?p=5117
https://auto-worker.com/blog/?p=5141

GASの実装

お待たせしました。やっと実装に入ります。

スプレッドシートの読み込み

まずは、

  • シート①: 提出管理用スプレッドシート
  • シート②: LINE管理用スプレッドシート

の2つをGASで読み込んでみましょう。
スプレッドシートにはファイルそれぞれにGoogleが勝手につけたIDがあります。
(これはスプレッドシートに限らず、Googleドライブ上で管理されるファイルには全てIDがあります。IDさえわかれば、画像ファイルなどもGAS上から簡単にアクセスできて便利です。)

スプレッドシートのIDは、ブラウザでスプレッドシートを開き、その時のURLのこの部分
https://docs.google.com/spreadsheets/d/<スプレッドシートのID>/edit#gid=...
です。Drive上やスプレッドシート内の「共有」ボタンから取得できる共有用URLにもIDは含まれていますが、URLの形式が違う場合があるので気をつけてください。

IDが分かれば、GASを開いて、IDなどの定数を管理するスクリプトファイルenv.gsを新規作成して以下のように書きます。

env.gs
const TEMP_SS_ID = "{シート①のID}"; // 提出管理用スプレッドシートのID
const TEMP_SS_SHEET_NAME = "管理用"; // 体温提出用スプレッドシートの未提出の確認に使うシートの名前

const LINE_SS_ID = "{シート②のID}"; // LINE管理用スプレッドシートのID
const LINE_SS_REMIND_SHEET_NAME = "未提出警告用"; // 夜に未提出の場合にリマインドするユーザを管理するシートの名前
const LINE_SS_SENDFORM_SHEET_NAME = "フォームURL送信用"; // 朝にフォームのURLを送るユーザを管理するシートの名前

ここで、SSはスプレッドシート(SpreadSheet)の略です。
また、シート名はスプレッドシートのファイル名ではなくて、スプレッドシート内の各シートの名前です。
どちらも「シート」と呼べるのでややこしいですが、下の写真のやつですね。

続いて、handle-spread-sheet.gsを作成して、以下のような関数を実装します。

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/handle-spread-sheet.gs#L5-L26

大まかな流れはコメントで書きましたが、いくつか補足していきます。

const values = sheet.getRange(2, 2, lastRow - 1, 3).getValues();

ここでは、シートの中のセルの範囲を指定して、その範囲のデータの内容を二次元配列で取得します。
それぞれの引数は以下の通りです。

getValuesの説明

第1,2引数は、基準となるセルの座標を指定し、第3,4引数ではそのセルから何行何列読み取るかをそれぞれ指定しています。

その次のここ、気になりませんでしたか?

if (name === "") break;

こんなことしなくても、getLastRow()でデータが存在する範囲だけを指定したからいいんじゃないの?って思うかと思います。が、ここに重大な罠が隠れています。 公式にはこの関数の説明はこう書かれています。

コンテンツが含まれている最後の行の位置を返します。

これも分かりにくいですよね。この関数は実は「値」が存在する最終行を取得するわけではないです。「書式」もコンテンツなんです。この恐ろしさ分かりますか?

今回のスプレッドシートは、下の方を見るとこんな感じになっています。

部員は30人程度なんで30行目くらいまでしか文字はないんですが、220行目までテーブルが用意されているんですよね。お察しの通り、この時getLastRow()では220が返ってきます。

なので、一旦余分にデータを取得したのちに空文字チェックを行なっているわけです。

そして最後に、Map型の変数にデータを格納しています。
ここで配列を使って

[
  {name1, true},
  {name2, true},
  ...
]

とせずに、Mapを使って

{
  name1: true,
  name2: true,
  ...
}

とした理由は、

ただ受け取ったメッセージをシート②に書き込むのではなく、シート①にその名前が存在するかを確認する必要がありそうです。

という処理を高速で実現するためです。
LINEで名前の文字列を受け取ったあと、その文字列の名前が存在するかの判定は、配列では逐次的にチェックする必要があるため、人数に比例した時間がかかります。(計算量でいう O(n)ですね)
連想配列だと、その文字列のキーが存在するかのチェックだけで済むので、O(1)で済むわけです。
Object型(連想配列)にしてもいいんですが、型を縛りやすいのでMapにしてます。

こんな感じで、システムの仕様や機能に合わせてデータ構造を決定することは、サービスを作る上で非常に大事です。

最後にこの関数をGAS上で実行して、出力を確認してみます。
Map型の出力は単純にconsole.log(userSubmittedMap)としたら{}と出力されうまくいかないので、for文でそれっぽく整形して出力しました。これを踏まえるとObject型でも良かったかな。

いけてそうですね!

同様に、シート②にある2つのシート

  • 未提出警告用シート
  • フォームURL送信用シート

を読み込む関数も実装していきます。
それぞれのシートは、データの内容が違うだけで、表の形式は一緒で、こんな感じにしています。
(まだuserIDの取得処理をしていないので、適当なダミーデータを入れています。)

注意点は、先ほどの罠対策をせずに済むよう、書式を設定していない点です。

それでは実装です。以下の内容を追記します

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/handle-spread-sheet.gs#L28-L69

ポイントは、

  1. 同じ形式の表に対する処理なので、共通のロジックは別関数に切り分けている点
  2. 別関数の語尾を_にすることで、private化している点(GASの実行する関数の候補から消えてくれる。この関数を直接実行することはないので)
  3. ユーザーが0人の時のコーナーケース処理

でしょうか。あとは基本的に先ほどの実装とやっていることは同じです。
今回も配列ではなく、userIDをkeyとするMapにしています。これは、後述する書き込み処理時に、既に存在するユーザーかをO(1)で判定するためです。

では、実行してログ出力しています。

グッジョブ!

スプレッドシートへの書きこみ

シートへの書き込みには、appendRow関数を使います。これにより、わざわざデータの最終行を取得しなくても一番末尾に指定したデータを書き込んでくれます。

書き込む際に、既に存在する場合はエラーにしたいです。他のエラーと区別するため、カスタムエラー型を作ります。

error.js
class AlreadyExistError extends Error {
  constructor(message) {
    super(message);
    this.name = "AlreadyExistError";
  }
}

また、ユーザー情報だけでなく、LINEで受け取ったメッセージや実行時エラーをログとして残したいので、ログ用のシートを作成します。シート名は「log」として、環境変数に追加します

env.gs
const LINE_SS_LOG_SHEET_NAME = "log"; // ログを記録するシートの名前 <- new!

そうすると、書き込みの実装は以下のようになります。

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/handle-spread-sheet.gs#L71-L115

今回も共通のロジックをputDataToSheet_()に抜き出すことで、3つのシートへ書き込むそれぞれの関数は見通しが良くなっています。

const remindUserMap = getUserInfoMapFromRemindSheet();
if (remindUserMap.has(userLineId)) {
  throw new AlreadyExistError(`userLineId "${userLineId}" is already exist.`);
}

とすることで、既に存在するユーザの場合は書き込まずにエラーを投げ、存在しない場合のみ書き込んでいます。

ここで唯一、可読性を重視して妥協した点があります。
それは、Mapを取得するためのget処理と書き込むためのput処理で、全く同じシートを2回開いている所です。本来は一回だけSheetオブジェクトを取得して、読み込みも書き込みも行うのが効率いいんですが、良い書き方ができず諦めました。Pythonとかだとオブジェクト指向ですんなりかけるんですけどね..

ログの部分では、[new Date(), data]を書き込むことで、timeStampを付けています。

では、適当な引数を与えて、

putUserInfoToRemindSheet(userLineId="hogeID", userName="hogeName")

で実行してみます。

ちゃんと追加されましたね!

LINEメッセージの送信関数

続いて、LINEのAPIを叩いてメッセージを送信する処理を書きます。

まずは環境変数を追加します。

env.js
const CHANNEL_ACCESS_TOKEN = "Your LINE Access Token";

続いて、実際にAPIを叩く処理です。

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/send-message.gs

ここでのpointは、

  • sendMessageをasync関数にしている
  • 送信失敗時にカスタムエラーを返している

の2点です。特に一つ目は超重要です。いわゆる非同期関数です。
ネットワーク通信により、APIの実行には時間がかかります。しかもこの時、通信を待って処理が止まってるので、コンピュータを持て余している状態です。これを有効活用するために、非同期関数を使って並列処理をします。

説明するとキリがないのでざっくりですが、非同期関数はPromiseという空の返り値を返すことで、呼び出し元は非同期関数の処理の終了を待たずに次のStepに進みます。処理の終了時にPromiseの中に真の返り値を入れるため、呼び出し元で待機することで真の返り値を受け取ることも可能です。
つまり、1秒待機する処理を10回行う場合、処理の開始を一気に行なって10個のPromiseを受け取り、全てのPromiseに真の返り値が入るまで同時に待機することで、待機時間を大幅に減らせるのです。これを逐次処理でやる場合、もちろん10秒かかります。

では、試しに実行してみましょう。非同期関数の終了を待つには、基本的には先頭にawaitをつけます。

await SendMessage(YOUR_USER_ID, "テスト送信");

こちらもうまくいってます!

mainの実行関数

いよいよ、メインとなる関数です。

  • 毎朝の実行関数(フォームのURL送信)をsendForm()
  • 毎晩の実行関数(未提出者へのリマインド)をremind()

とし、これらをGASの時間帯指定のトリガーに設定することで実行します。
どちらもユーザーリストとメッセージが異なるだけなので、片方だけ載せます。

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/main.gs#L1-L25

まぁまぁややこいことしてますが、最終行の

await Promise.all(Array.from(remindUserMap).map(sendMessageHandler))

が肝です。await Promise.all(..)は、複数のPromiseを受け取りその全てが解決されるまで(真の返り値が入るまで)待機できる関数です。なので、引数にはPromiseの配列が入ります。
Array.from(remindUserMap)でユーザーのMapを配列に変換し、その配列にmap関数を適用してるのですが、map関数にasync関数を渡すことで、配列の要素一つ一つがPromiseに変わるわけです。
言い換えると、配列の全てのvalueに対してsendMessageHandler(value)を並列に実行し、その全ての処理が終わるまで待機してるってわけですね。

sendMessageHandler()では、体温未提出のユーザーだった場合にLINEメッセージを送っています。
気持ち悪いのはここでしょうか

await sendMessage(
  userLineId,
  `${userName}さん、体温を提出してください。`
).catch((error) => putDataToLogSheet(error.message));

async関数には、エラー時のコールバック関数を渡すことができます。

try {
  await sendMessage(
     userLineId,
     `${userName}さん、体温を提出してください。`
  )
} catch (error) {
  putDataToLogSheet(error.message);
}

とやってることは変わらないのですが、こういう書き方もできるよという紹介です。
いずれにせよエラー処理がない場合、一つのエラーで並行処理中の全てが止まってしまいますので注意してください。

では、remind()を実行してみます。
ここで、LINE APIのガイドラインには、存在しないユーザーIDへのリクエストの禁止と書かれているので、ダミーのUserをスプレッドシートに載せたまま実行しないように気をつけてください。
並列処理のテストは行えませんが、仕方ないのでユーザー自分一人だけで実行します。

こちらも問題なさそうです!
また、Googleフォームで体温を提出してから再度実行すると、LINEが送られてこないのも確認できます。

doPost関数

最後に、 LINE APIのdoPost関数の実装ですが、ここはフローがややこしいです。
名前が来て欲しい場面で名前が来たり来なかったり、毎朝のフォーム送信を「希望するか」を聞いたのに名前を送られたり、名前が送られたけど既に登録済の場合だったり、「希望」っていう人名だった場合の考慮だったり、、
と、かなりごちゃごちゃなif分岐が出来上がりそうです。

そこで、3つの状態を定義して状態ごとに処理を切り分けました。

  • 状態1: 毎晩のリマインド登録がまだ(すなわち毎朝のフォーム送信登録もまだ)
  • 状態2: リマインド登録済。フォーム送信登録はまだ。
  • 状態3: 両方登録済

このように3つの状態に分けてから考えることで、かなり考えやすくなります。
状態1では、名前が存在するかの場合分けだけ。
状態2では、メッセージが「希望する」にmatchするかの場合分けだけ。
状態3では、特に何もしない。

順番の制約は増えますが、実装は見通しがよくなりました。
ちなみに状態3は何もしないと言いつつも、メッセージを送って何も返答がないのはバグを疑われるし良くないので、メッセージをそのままオウム返ししています。

https://github.com/n-hizume/LineReminder/blob/35f1ab6bafba9684a3acaa4ede7cc97f924bae5e/line-callback.gs

これで実装は完了です!

GAS上でWebアプリとしてデプロイし、取得したURLをLINE Messaging APIのwebhook URLに登録してください。
また、友達追加時にbotから送るメッセージも、LINE DEvelopersの方で追加します。
今回は、最初に体温管理シートにある名前を送って欲しいので、その旨を送ります。

それが済んだらスプレッドシートからダミーデータを消し、まっさらな状態にしましょう。
それでは、QRコードから友達追加して、botと会話を行なってみます。

こんな感じの会話ができ、スプレッドシートにIDと名前が追加されたら全て完了です!!
かなり説明が長くなりましたが、実装は以上です。

あとは、remind()sendForm()をGAS上で時間指定のトリガーに設定したら、システムとしても完成です!

実装全体で注意したこと

アーキテクチャ

今回は小規模でコード量自体はそこまで多くないですが、それでもファイルやコードの分離は適切に行なっています。
特に、

  • スプレッドシート操作に関するコード
  • LINEのメッセージ操作に関するコード

をファイル単位で分離し、それらをmainファイルやcallback用のファイルで呼び出すような構成にしています。
これにより、バグが見つかった時や機能追加したい時でも、修正すべきファイルが明確です。
また、例えば「LINEではなくてSLackなど違うアプリのbotに変更したい」ような時も、依存関係がないためスプレッドシート操作に関するコードは修正をせずに済みます。

アーキテクチャに正解はないですが、システムの規模感や種類に応じて、複数の概念が依存し合ったり、特定の処理がどこで行われているか分からなかったりすることがないように設計する姿勢が可読性や保守性を上げるために大事だと思います。

コメント

各関数には、JSDocと呼ばれる形式のコメントを残しています。 
本来は実装仕様をコメントから簡単にドキュメント生成するために使われるもので、↓こんなやつです。

/**
 * hoge関数
 * @params {string} x 引数
 * @returns {string} 返り値
 */
function hoge(x) { ...

普段はVSCodeを使って開発していますが、jsではインターフェース定義や静的型付けやが行えず、存在しない変数やプロパティを指定しても怒ってくれません。また、返り値は全てany型と判定され、Objectのプロパティのサジェストも効きません。それを少しでもマシにするのがこれです。これを行うことで、エディタによる性的チェックやサジェストが多少効くようになります。コンパイラの機構ではないので、コンパイラエラーになるわけではありませんが。

まとめ

長くなりましたが、一通り動くLINE botシステムを作ることができました。
高速化や保守性・可読性のための実装の細かい工夫などもお伝えできたかなと思います。

今回のコードは、Github上で公開しています。
何かコメントやアドバイス等ありましたら、いつでもお待ちしています!

Discussion