何を自動化したか
LINE公式アカウントに届いたメッセージに、僕が確認するより先に一次返信が返る仕組みと、体験予約・入会・講習申込・面談予約をフォーム入力だけで完結させる仕組みを、Google Apps Script(GAS)だけで作った。塾のような対面ビジネスは「問い合わせから返信までの速さ」が成約率に直結する。深夜や指導中にLINEが来ても、キーワードに応じた案内文とフォームのリンクを1秒で返し、同時に自分のGmailに着信通知が届く。
効果を秒単位で計測してはいないため数字は体感になるが、以前は「授業が終わってから折り返す」までの数時間〜半日の空白があったのに対し、いまはその空白がゼロになった。フォーム側も、電話・LINEでの聞き取りをやめてスマホ入力だけで完結するようにしたことで、こちらの対応漏れがなくなった。
仕組み(データの流れ)
入口は2つある。ひとつはLINEのテキストメッセージ、もうひとつは公開フォーム(体験・入会・講習・面談)。どちらも最終的に同じスプレッドシートのハブに集まる。
| 入力 | 処理 | 出力 |
|---|---|---|
| LINEのテキスト | キーワード判定→定型文生成→LINE Reply API | ユーザーへ即時返信+オーナーへ着信通知 |
| 体験予約フォーム | 「体験生フォロー」シートへ追記 | カレンダー仮予定+オーナー通知(このシートは体験生フォローの自動化の入口にもなる) |
| 入会・講習・面談フォーム | それぞれの申込シートへ追記、講習は残席チェック | オーナー通知(満席なら申込自体を拒否) |
LINEのWebhookは1本のGASプロジェクトのdoPostに届く。そこからテキストメッセージだけを自動応答の関数に振り分け、画像メッセージは別のOCR処理に振り分けている。1つのdoPostに複数の役割を持たせているのは、GASのWebアプリは1プロジェクトにつき1つの公開URLしか持てず、LINEのWebhook URLを複数用意するのが現実的でないためだ。
コード(要点)
Webhookの入口はこうなっている。テキストメッセージだけを自動応答関数へ渡し、それ以外(画像など)は既存のOCR処理を通す。
function doPost(e) {
if (e && e.parameter && e.parameter.app === 'pixi') return pixiApi(e);
try {
var events = JSON.parse(e.postData.contents).events;
events.forEach(function(event) {
if (event.type === "message" && event.message.type === "image") handleImage(event);
else if (event.type === "message" && event.message.type === "text") bridgeLineAutoReply(event);
});
} catch (err) {
// LINE webhookには常に200を返す必要があるため、エラーはログのみ
Logger.log('doPost エラー: ' + err.message);
}
}
自動応答本体はキーワードの正規表現マッチだけで、AIは呼んでいない。返信は定型文+フォームURLの組み合わせで、応答できなかった場合も含めてオーナーには必ず通知が飛ぶ。
function bridgeLineAutoReply(event) {
var text = (event.message && event.message.text) || '';
if (!text) return;
var token = PropertiesService.getScriptProperties().getProperty('LINE_ACCESS_TOKEN');
var base = PropertiesService.getScriptProperties().getProperty('WEBAPP_URL') || '';
var reply = null;
if (/体験|見学|たいけん/.test(text)) {
reply = 'メッセージありがとうございます!\n無料体験は下のフォームから24時間いつでもご予約いただけます\n'
+ (base ? base + '?app=trial' : '');
} else if (/料金|月謝|費用|いくら/.test(text)) {
reply = 'お問い合わせありがとうございます!料金は折り返しご案内します。\n' + (base ? base + '?app=trial' : '');
}
// ...入会・講習・面談も同様のパターンマッチで分岐
if (reply && token && event.replyToken) {
UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
method: 'POST',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify({ replyToken: event.replyToken, messages: [{ type: 'text', text: reply }] }),
muteHttpExceptions: true,
});
}
// 応答の有無に関わらず、問い合わせ着信をオーナーに即通知する
}
フォーム側は、体験予約なら「体験生フォロー」シートへの追記とカレンダー登録をセットで行う。カレンダー登録が失敗しても申込自体は成立させたいので、try/catchで独立させている。
function bridgeCreateEvent(title, dateStr, timeStr, desc) {
try {
var start = new Date(dateStr);
if (isNaN(start.getTime())) return;
var hour = 17, minute = 0;
var m = String(timeStr || '').match(/(\d{1,2})[:時](\d{0,2})/);
if (m) { hour = Number(m[1]); minute = Number(m[2] || 0); }
start.setHours(hour, minute, 0, 0);
var end = new Date(start.getTime() + 60 * 60 * 1000);
CalendarApp.getDefaultCalendar().createEvent(title, start, end, { description: desc || '' });
} catch (e) {
Logger.log('カレンダー登録失敗: ' + e.message);
}
}
フォーム送信の受け口は種別ごとに関数を分けたうえで、共通の入口関数でルーティングしている。長い分岐部分は省くが、考え方としては「1つのdoPost/1つの受け口関数に対して、種別ごとの処理関数を素直に並べる」だけで、フレームワークは使っていない。全文はnoteへ。
ハマった点と回避策
- LINE Webhookは何があっても200を返す。途中でエラーが起きても
doPost全体をtry/catchで包み、ログにだけ残す設計にした。LINE側はエラーレスポンスが続くとWebhookを自動で無効化することがあるため、業務ロジックのエラーとWebhookの応答コードは完全に切り離す必要がある。 - GASのWebアプリはiframe内に表示されるため、通常の
<a href>によるページ遷移がブロックされることがある。初期セットアップ画面で「ボタンを押しても画面が変わらない」現象が起きたため、リンク遷移ではなくgoogle.script.runでその場に結果を描画する方式に変更した。 - カレンダー登録の失敗で申込全体を止めない。カレンダーAPIは権限エラーや日時パース失敗で例外を投げることがあるが、それで体験予約自体が失敗扱いになるのは本末転倒なので、独立した関数にしてtry/catchで包んだ。
- 自動返信できなかったメッセージほど埋もれやすい。キーワードにマッチせず定型文を返せなかった場合でも、必ずオーナー通知だけは飛ぶようにしている。自動応答は「一次対応」であって「全対応」ではないという前提で設計するのが安全だった。
導入手順
- LINE DevelopersでMessaging APIのチャネルを作成し、チャネルアクセストークンを発行する。
- GASプロジェクトのスクリプトプロパティ(「プロジェクトの設定」→「スクリプトプロパティ」)に
LINE_ACCESS_TOKENを登録する。コードにトークンを直接書かない。 - GASを「ウェブアプリとして導入」し、発行されたURLをLINE Developersの「Webhook URL」に設定して有効化する。
- 初回アクセス時に
WEBAPP_URLがスクリプトプロパティへ自動保存されるようにしておくと、フォームURLの組み立てが以後は自動になる。 doPostでテキストメッセージを自動応答関数へ、画像メッセージを別処理へ振り分ける条件分岐を書く。- 体験・入会・講習・面談それぞれのフォーム送信の受け口を用意し、対応するスプレッドシートのシート名・列見出しを決めて追記処理を書く。
- LINE公式アカウントのリッチメニューに、ウェブアプリURLに
?app=trial等のパラメータを付けたリンクを設定する。 - 本番投入前に、自分のLINEアカウントから「体験」「料金」などのキーワードを実際に送って返信内容を確認する。
よくある質問
Q. AIを使わずにキーワードマッチだけにしているのはなぜですか
一次応答は速さが命で、AI呼び出しのレイテンシと料金をかける必要がないと判断したため。定型文で対応できない込み入った相談は、返信の中で「教室から直接連絡する」旨を伝え、人が引き取る前提にしている。
Q. 1つのGASプロジェクトにWebhookとフォームを同居させて問題はないですか
Webhookの受け口であるdoPostにe.parameter.appの有無で処理を分けているだけなので、リクエストの種類が増えても分岐を足すだけで対応できている。プロジェクトが肥大化してきたら分割も検討するが、現時点ではトリガー数や実行時間の制約に達していない。
Q. フォームの入力データはどこに保存されますか
「体験生フォロー」「入会申込」「講習申込」「面談予約」といったシート名でスプレッドシートに保存している。IDそのものはスクリプトプロパティで管理し、コードには書いていない。
この仕組みの「全体像」と「コード全文」
- Kindle『個人塾長、AIを雇う。』(500円・Kindle Unlimited対応)2年間の実録を1冊に。何をどの順で自動化したか、失敗も含めて
- note『個人塾をGASで自動化した実録(実コード付き)』(1,800円)この記事で省略したコード全文と、貼り付け手順
- English edition: How I Automated My One-Man Tutoring School with AI ($6.99)