最近、バイブコーディング(Vibe coding)「生成AIに指示を出してコードを書いてもらうこと」を色々と試みています。
こんな仕組みを自分で実装出来ないかな?
バイブコーディングを使えば、作れるのでは?と思い挑戦してみました。
【 LINEbotで顧客管理 】
定期的にメッセージを送り、意見を伺う。
~ LIFFフォーム登録 → スプレッドシート管理 → 定期LINE通知&SMSフォールバックまで ~
🎯構成
- GASでLINE Botを動かす
- LIFFフォームで氏名/電話番号/基準日を収集
- スプレッドシートに登録
- 基準日の1・3・6か月後にLINEメッセージを自動送信(クイックリプライ付)
- 7日間返信が無い場合はTwilioでSMSを自動送信
- 返信内容と確認日時を自動的にスプレッドシートへ記録
顧客情報の登録
1カ月目LINEメッセージ 「問題なし」の返答
作成手順
🪜 作成手順
1️⃣ LINE Bot を作成する
1. LINE Developers にアクセス
LINE公式アカウントの作成 / LINE Botの初め方(https://zenn.dev/protoout/articles/16-line-bot-setup) を参照。
(1). LINE Developersにログイン → プロバイダーを作成(任意名)。
(2). 新規チャネル作成 → 「Messaging API」を選択。
- Messaging APIは現在LINE Developersから直接作成できないので、開いたページ内の「LINE公式アカウントを作成する」をクリックし作成する。
- BotはLINE Developersに登録されたLINEへ自動的に登録されます。
(3). Messaging APIの有効化
- LINE Official Account Manager(https://manager.line.biz/) を開き新規作成したアカウントを確認、選択してページを開く。
- 左上メニューの「チャット」を開き、「応答メッセージ」をオフにする。
- 左側メニューの「Messaging API」 =>「 Messaging APIを利用する」と進み、プロバイダーを選択し、同意するをクリックします。
(4). 作成後、「チャネル基本設定」でチャネルID/チャネルシークレットを確認。
(5). 「Messaging API設定」で「チャネルアクセストークン(長期)」を発行→コピー(GASで使用)。
(6). WebhookはGAS公開後に設定します。
2. 設定
(1). 「Messaging API設定」→「チャネルアクセストークン(長期)」を発行 → コピーして保存
(2). 後でGASのスクリプトプロパティに設定します
(3). WebhookはGASデプロイ後に設定します
2️⃣ スプレッドシートを作成
シート1: Customers
| 列 | 項目 |
|---|---|
| A | customer_id |
| B | name |
| C | line_user_id_masked |
| D | phone |
| E | anchor_date |
| F | m1_line_sent_at |
| G | m1_engaged_at |
| H | m1_engaged_message |
| I | m1_sms_sent_at |
| J | m3_line_sent_at |
| K | m3_engaged_at |
| L | m3_engaged_message |
| M | m3_sms_sent_at |
| N | m6_line_sent_at |
| O | m6_engaged_at |
| P | m6_engaged_message |
| Q | m6_sms_sent_at |
シート2: Messages
| 列 | 項目 | 1カ月目 | 3カ月目 | 6カ月目 |
|---|---|---|---|---|
| A | key | 1カ月目 | 3カ月目 | 6カ月目 |
| B | LINE文面 | ◯◯ さま、1カ月目のご案内です。 | ◯◯ さま、3カ月目のご案内です。 | ◯◯ さま、6カ月目のご案内です。 |
| C | SMS文面 | ◯◯さま、先日のご案内の再送です(1カ月目)。 | ◯◯さま、先日のご案内の再送です(3カ月目)。 | ◯◯さま、先日のご案内の再送です(6カ月目)。 |
3️⃣ Twilio(SMS送信サービス)設定
Twilioに新規登録&ログイン
- https://www.twilio.com を開き、新規登録&ログイン
Console → API Keys → 「Create API key」
SID(Account SID) と Secret(Auth Token) を控える
- Consoleの一番下「API Keys」のGo to API Keysをクリック(携帯に送られた認証コードを入力)しページを開く。
- 右側の「Create API key」をクリックしAPIを作成する。
- SID(Account SID) / Secret(Auth Token) を控えておく(GASで使用)
Phone Numbers → Buy a Number
「SMS対応」の番号を選ぶ(Trialなら無料)
- Consoleの左側メニュー「Phone Numbers」→「Buy a Number」と進み、「SMS対応」の番号を選んで購入(Trialなら無料で1つ提供される(例:+15017122661))(From番号)。
Verified Caller ID に自分の携帯番号を登録
Trial中は登録済み番号にしか送信できません。
- Trial番号の場合、送信先は「Verified Caller IDs」に自分の携帯番号を登録・認証する必要がある(Trial番号の場合、登録された電話番号以外にはSMSは送信されない仕組み)。
- Consoleの左側メニュー「Verified Caller IDs」を開き、右上の「Add a new celler ID」をクリック、送信したい電話番号を登録する。認証SMSが届くので承認する。
- (Messaging → Settings → Geo permissions で Japan(+81) を許可。:これはしなくても大丈夫)
4️⃣ GAS を作成・設定・デプロイ
1. スプレッドシート → 拡張機能 → Apps Script
既存コードを削除し、以下のコード全文を貼り付けて保存します。
💻 GASコード全文
GAS(コード.gs)
/*** --- 設定(スクリプトプロパティ) --- ***/
/***** 設定(スクリプトプロパティ & シート名) *****************************/
const LINE_CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_CHANNEL_ACCESS_TOKEN");
const TWILIO_SID = PropertiesService.getScriptProperties().getProperty("TWILIO_ACCOUNT_SID");
const TWILIO_AUTH = PropertiesService.getScriptProperties().getProperty("TWILIO_AUTH_TOKEN");
const TWILIO_FROM = PropertiesService.getScriptProperties().getProperty("TWILIO_FROM_NUMBER");
// 追跡リンクは使わない(未設定でもOK)
const WEBAPP_BASE = PropertiesService.getScriptProperties().getProperty("WEBAPP_BASE") || '';
const TZ = 'Asia/Tokyo';
const SHEET_CUSTOMERS = 'Customers';
const SHEET_MESSAGES = 'Messages';
const ss = SpreadsheetApp.getActiveSpreadsheet();
const C_Sheet = ss.getSheetByName(SHEET_CUSTOMERS);
const M_Sheet = ss.getSheetByName(SHEET_MESSAGES);
/** 1/3/6か月の列マッピング(Customersシートの列) */
const MILESTONES = [
// F:G:H:I
{ key: '1カ月目', months: 1, cols: { line:'F', engaged:'G', engaged_msg:'H', sms:'I' } },
// J:K:L:M
{ key: '3カ月目', months: 3, cols: { line:'J', engaged:'K', engaged_msg:'L', sms:'M' } },
// N:O:P:Q
{ key: '6カ月目', months: 6, cols: { line:'N', engaged:'O', engaged_msg:'P', sms:'Q' } },
];
/***** 共通ユーティリティ ***************************************************/
function createOk_() {
return ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT);
}
function fmt_(d, pat='yyyy-MM-dd HH:mm:ss') {
return Utilities.formatDate(d, TZ, pat);
}
function today0_() {
return new Date(Utilities.formatDate(new Date(), TZ, 'yyyy/MM/dd 00:00:00'));
}
function asDate0_(v) {
if (!v) return null;
if (Object.prototype.toString.call(v) === '[object Date]') {
return new Date(v.getFullYear(), v.getMonth(), v.getDate());
}
const s = String(v).trim();
const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (m) return new Date(Number(m[1]), Number(m[2])-1, Number(m[3]));
const t = Date.parse(s);
if (!isNaN(t)) {
const d = new Date(t);
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
return null;
}
function addMonths_(date, months) {
const d = new Date(date.getTime());
const m = d.getMonth() + months;
d.setMonth(m);
if (d.getMonth() !== ((m % 12) + 12) % 12) d.setDate(0); // 月末補正
return d;
}
function daysBetween_(a, b) {
const ms = 1000*60*60*24;
return Math.floor((b - a)/ms);
}
function colLetterToIndex_(letter) { // 'A'->0
let n = 0; for (let i=0;i<letter.length;i++) n = n*26 + (letter.charCodeAt(i)-64);
return n-1;
}
/** ユーザーIDのマスク/復元(ご提供ロジック準拠) */
function maskFromUserId_(userId) {
const lastIndex = userId.length;
const rest = userId.slice(0, lastIndex - 6);
const last6 = userId.slice(-6);
return last6 + rest;
}
function unmaskUserId_(userId_CC) {
if (!userId_CC || userId_CC.length < 7) return userId_CC;
return userId_CC.slice(6) + userId_CC.slice(0,6);
}
/***** メッセージテンプレ取得 ***********************************************/
function getMessageFor_(milestoneKey) {
const last = M_Sheet.getLastRow();
if (last < 2) return null;
const values = M_Sheet.getRange(2,1,last-1,3).getValues(); // A〜C
for (const [ms, lineText, smsText] of values) {
if (String(ms).trim() === milestoneKey) {
return { lineText: String(lineText||''), smsText: String(smsText||'') };
}
}
return null;
}
/** LINE本文(追跡リンクなし、カッコなし) */
function buildLineBody_(baseText, name, milestoneKey) {
const fallback = `${name} さま、${milestoneKey}のご案内です。`;
if (!baseText) return fallback;
return String(baseText).replaceAll('◯◯', name) || fallback;
}
/** クイックリプライ(2択) */
function buildQuickReply_(milestoneKey) {
return {
items: [
{
type: 'action',
action: {
type: 'postback',
label: '変わり有りません',
data: `ack=nochange&ms=${milestoneKey}`,
displayText: '変わり有りません'
}
},
{
type: 'action',
action: {
type: 'postback',
label: '変わり有ります',
data: `ack=change&ms=${milestoneKey}`,
displayText: '変わり有ります'
}
}
]
};
}
/***** LINE送信ユーティリティ **********************************************/
function replyText_(replyToken, text){
const url = 'https://api.line.me/v2/bot/message/reply';
const headers = {
"Content-Type": "application/json; charset=UTF-8",
"Authorization": "Bearer " + LINE_CHANNEL_ACCESS_TOKEN
};
const postData = { replyToken, messages: [{ type: "text", text }] };
const options = { method:"post", headers, payload: JSON.stringify(postData), muteHttpExceptions:true };
const res = UrlFetchApp.fetch(url, options);
if (res.getResponseCode() !== 200) console.error('LINE reply error', res.getResponseCode(), res.getContentText());
}
function pushMessage_(toUserId, messageObj) {
const url = 'https://api.line.me/v2/bot/message/push';
const payload = { to: toUserId, messages: [ messageObj ] };
const res = UrlFetchApp.fetch(url, {
method:'post',
contentType:'application/json; charset=utf-8',
headers:{ Authorization:'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) console.error('LINE push error', res.getResponseCode(), res.getContentText());
}
/***** Twilio SMS ***********************************************************/
function sendSmsTwilio_(toNumber, body) {
if (!TWILIO_SID || !TWILIO_AUTH || !TWILIO_FROM) {
console.error('Twilio settings missing');
return;
}
const url = `https://api.twilio.com/2010-04-01/Accounts/${TWILIO_SID}/Messages.json`;
const payload = { From: TWILIO_FROM, To: toNumber, Body: body };
const options = {
method: 'post',
payload,
muteHttpExceptions: true,
headers: { Authorization: 'Basic ' + Utilities.base64Encode(TWILIO_SID + ':' + TWILIO_AUTH) },
};
const res = UrlFetchApp.fetch(url, options);
if (res.getResponseCode() >= 300) console.error('Twilio SMS failed', res.getResponseCode(), res.getContentText());
}
/***** Customers 行確保/検索 **********************************************/
function ensureRowByMaskedId_(userId_CC) {
const lastRow = C_Sheet.getLastRow();
const n = Math.max(0, lastRow - 1);
const dat = n ? C_Sheet.getRange(2, 3, n, 1).getValues().flat() : [];
const idx = dat.indexOf(userId_CC);
if (idx !== -1) return idx + 2;
const inputRow = lastRow + 1;
const C_number = "CUST-" + Utilities.formatString("%04d", lastRow);
C_Sheet.getRange(inputRow, 1).setValue(C_number);
C_Sheet.getRange(inputRow, 3).setValue(userId_CC);
return inputRow;
}
/***** 汎用エンゲージ記録(使い所限定) *************************************/
function markEngagedByMaskedId_(userId_CC, msKey /* optional */) {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
for (let i=0; i<values.length; i++) {
const row = values[i];
if (String(row[2]||'').trim() === userId_CC) {
const stamp = fmt_(new Date());
if (msKey) {
const msObj = MILESTONES.find(m => m.key === msKey);
if (msObj) {
const idxEng = colLetterToIndex_(msObj.cols.engaged);
C_Sheet.getRange(i+2, idxEng+1).setValue(stamp);
return;
}
}
// ms不明 → 直近の未エンゲージに記録(6→3→1の順)
const ordered = MILESTONES.slice().reverse();
for (const m of ordered) {
const idxLine = colLetterToIndex_(m.cols.line);
const idxEng = colLetterToIndex_(m.cols.engaged);
if (row[idxLine] && !row[idxEng]) {
C_Sheet.getRange(i+2, idxEng+1).setValue(stamp);
return;
}
}
return;
}
}
}
/***** 追加:確認日時 / 入力内容の記録 **************************************/
// 確認日時(G / K / O)
function setEngagedTimestamp_(maskedId, milestoneKey, dateObj) {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
const ms = MILESTONES.find(m => m.key === milestoneKey);
if (!ms) return;
const idxEng = colLetterToIndex_(ms.cols.engaged);
for (let i=0; i<values.length; i++) {
if (String(values[i][2]||'').trim() === maskedId) {
C_Sheet.getRange(i+2, idxEng+1).setValue(fmt_(dateObj));
return;
}
}
}
// 入力内容(H / L / P)— 上書き(必要なら追記に変更可)
function setEngagedMessage_(maskedId, milestoneKey, text) {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
const ms = MILESTONES.find(m => m.key === milestoneKey);
if (!ms) return;
const idxMsg = colLetterToIndex_(ms.cols.engaged_msg);
for (let i=0; i<values.length; i++) {
if (String(values[i][2]||'').trim() === maskedId) {
C_Sheet.getRange(i+2, idxMsg+1).setValue(text);
return;
}
}
}
/***** 「変わり有ります」入力待ちフラグ(ScriptPropertiesで保持) *********/
function setAwaitingChangeFlag_(maskedId, milestoneKey) {
PropertiesService.getScriptProperties().setProperty('AWAIT_' + maskedId, milestoneKey);
}
function getAwaitingChangeFlag_(maskedId) {
return PropertiesService.getScriptProperties().getProperty('AWAIT_' + maskedId) || null;
}
function clearAwaitingChangeFlag_(maskedId) {
PropertiesService.getScriptProperties().deleteProperty('AWAIT_' + maskedId);
}
/***** 1) 登録日+1/3/6か月「当日ちょうど」→ LINE送信 **********************/
function checkAndSendLineDaily() {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
const today0 = today0_(); // JST 0:00
for (let i=0;i<range.length;i++) {
const row = range[i];
const customer_id = String(row[0]||'').trim();
const name = String(row[1]||'').trim() || 'お客さま';
const maskedId = String(row[2]||'').trim();
const anchor0 = asDate0_(row[4]); // 投与日 E列
if (!customer_id || !maskedId || !anchor0) continue;
const lineUserId = unmaskUserId_(maskedId);
for (const ms of MILESTONES) {
const dueRaw = addMonths_(anchor0, ms.months);
const due0 = new Date(dueRaw.getFullYear(), dueRaw.getMonth(), dueRaw.getDate());
const idxLine = colLetterToIndex_(ms.cols.line);
if (row[idxLine]) continue; // 送信済みはスキップ
if (daysBetween_(due0, today0) === 0) { // 当日ちょうど
const tmpl = getMessageFor_(ms.key);
const lineBody = buildLineBody_(tmpl && tmpl.lineText, name, ms.key);
const messageObj = { type: 'text', text: lineBody, quickReply: buildQuickReply_(ms.key) };
try {
pushMessage_(lineUserId, messageObj);
C_Sheet.getRange(i+2, idxLine+1).setValue(fmt_(new Date())); // 送信時刻
} catch (e) {
console.error('LINE push failed', e);
}
}
}
}
}
/***** 2) 7日無反応 → SMSフォールバック ***********************************/
function checkAndSendSmsFallbackDaily() {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
const now = new Date();
for (let i=0;i<range.length;i++) {
const row = range[i];
const customer_id = String(row[0]||'').trim();
const name = String(row[1]||'').trim() || 'お客さま';
const phone = String(row[3]||'').trim();
if (!customer_id || !phone) continue;
for (const ms of MILESTONES) {
const idxLine = colLetterToIndex_(ms.cols.line);
const idxEng = colLetterToIndex_(ms.cols.engaged);
const idxSms = colLetterToIndex_(ms.cols.sms);
const lineSentAt = row[idxLine] ? new Date(row[idxLine]) : null;
const engagedAt = row[idxEng] ? new Date(row[idxEng]) : null;
const smsSentAt = row[idxSms] ? new Date(row[idxSms]) : null;
if (!lineSentAt || engagedAt || smsSentAt) continue;
const elapsed = daysBetween_(asDate0_(lineSentAt), asDate0_(now));
if (elapsed >= 7) {
const tmpl = getMessageFor_(ms.key);
const smsBody = (tmpl && tmpl.smsText ? tmpl.smsText : `${name}さま、LINEのご案内の再送です(${ms.key})`).replaceAll('◯◯', name);
try {
sendSmsTwilio_(phone, smsBody);
C_Sheet.getRange(i+2, idxSms+1).setValue(fmt_(new Date()));
} catch (e) {
console.error('SMS send failed', e);
}
}
}
}
}
/***** Webhook(登録/返信/ポストバック) ***********************************/
function doPost(e) {
try {
const body = e.postData && e.postData.contents ? e.postData.contents : '';
if (!body) return createOk_();
const json = JSON.parse(body);
if (!json.events || !Array.isArray(json.events)) return createOk_();
json.events.forEach(ev => {
const userId = ev.source && ev.source.userId;
const replyToken = ev.replyToken || null;
if (ev.type === 'message') {
const msg = ev.message || {};
if (!userId || msg.type !== 'text') return;
const userMessage = msg.text || '';
const maskedId = maskFromUserId_(userId);
// 「変わり有ります」の入力待ちか?
const awaitingMsKey = getAwaitingChangeFlag_(maskedId);
if (awaitingMsKey) {
// 入力内容を所定列へ記載し、返信は固定文
setEngagedMessage_(maskedId, awaitingMsKey, userMessage);
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
clearAwaitingChangeFlag_(maskedId);
return;
}
// 以降:登録メッセージ処理(任意)
const inputRow = ensureRowByMaskedId_(maskedId);
if (userMessage.includes('登録')) {
if (C_Sheet.getRange(inputRow, 2).getValue() === "") {
const user_name = extractText(userMessage, "氏名:") || extractText(userMessage, "お名前:") || '';
if (user_name) C_Sheet.getRange(inputRow, 2).setValue(user_name.trim());
}
if (C_Sheet.getRange(inputRow, 4).getValue() === "") {
const user_phone = (extractText(userMessage, "電話:") || '').trim();
if (user_phone) {
const phone_e164 = user_phone.startsWith('+') ? user_phone
: (user_phone.startsWith('0') ? '+81' + user_phone.substring(1) : user_phone);
C_Sheet.getRange(inputRow, 4).setValue(phone_e164);
}
}
const medDate = (extractText(userMessage, "登録日:") || extractText(userMessage, "登録日:") || '').trim();
if (medDate) C_Sheet.getRange(inputRow, 5).setValue(medDate);
if (replyToken) replyText_(replyToken, "ご登録ありがとうございます。");
} else {
// 通常返信=エンゲージ扱い(どの期か不明の場合の保険)
markEngagedByMaskedId_(maskedId, null);
if (replyToken) replyText_(replyToken, "ご用件は何でしょうか。");
}
}
if (ev.type === 'postback') {
const data = ev.postback && ev.postback.data || '';
const msKey = getQueryParam_(data, 'ms'); // '1カ月目' / '3カ月目' / '6カ月目'
const ack = getQueryParam_(data, 'ack'); // 'nochange' / 'change'
if (userId && msKey) {
const maskedId = maskFromUserId_(userId);
// 「確認日時」を該当列へ記録(G / K / O)
setEngagedTimestamp_(maskedId, msKey, new Date());
if (ack === 'nochange') {
// H / L / P に「変わり有りません」を記載し、返信も固定文
setEngagedMessage_(maskedId, msKey, '変わり有りません');
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
} else if (ack === 'change') {
// 入力待ちフラグを立て、入力促しの案内を即返信
setAwaitingChangeFlag_(maskedId, msKey);
if (replyToken) {
replyText_(replyToken, 'ご返信ありがとうございます。\nどのような変化がありましたか?ご自由にご記入ください。');
}
}
}
}
if (ev.type === 'follow') {
if (userId) ensureRowByMaskedId_(maskFromUserId_(userId));
}
});
return createOk_();
} catch (err) {
console.error(err);
return createOk_();
}
}
/***** doGet(未使用) *****************************************************/
function doGet(e) {
return createOk_();
}
/***** 顧客IDベースのEngaged(必要ならクリック等から呼べる) **************/
function markEngagedByCustomerId_(customerId, msKey /* optional */) {
const last = C_Sheet.getLastRow();
if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues(); // A〜Q
for (let i=0; i<values.length; i++) {
const row = values[i];
if (String(row[0]||'').trim() === customerId) {
const stamp = fmt_(new Date());
if (msKey) {
const msObj = MILESTONES.find(m => m.key === msKey);
if (msObj) {
const idxEng = colLetterToIndex_(msObj.cols.engaged);
C_Sheet.getRange(i+2, idxEng+1).setValue(stamp);
return;
}
}
const ordered = MILESTONES.slice().reverse();
for (const m of ordered) {
const idxLine = colLetterToIndex_(m.cols.line);
const idxEng = colLetterToIndex_(m.cols.engaged);
if (row[idxLine] && !row[idxEng]) {
C_Sheet.getRange(i+2, idxEng+1).setValue(stamp);
return;
}
}
return;
}
}
}
/***** 便利:トリガー作成(任意実行) **************************************/
function createDailyTriggers() {
ScriptApp.newTrigger('checkAndSendLineDaily').timeBased().atHour(9).nearMinute(0).inTimezone(TZ).everyDays(1).create();
ScriptApp.newTrigger('checkAndSendSmsFallbackDaily').timeBased().atHour(9).nearMinute(10).inTimezone(TZ).everyDays(1).create();
}
/***** ヘルパ(ハッシュ・テキスト抽出・クエリ取得) ************************/
function extractText(text, targetString) {
var newlineChar = "\n";
var startIndex = text.indexOf(targetString);
if (startIndex === -1) return '';
var textAfterTarget = text.substring(startIndex + targetString.length);
var newlineIndex = textAfterTarget.indexOf(newlineChar);
return (newlineIndex === -1) ? textAfterTarget : textAfterTarget.substring(0, newlineIndex);
}
function getQueryParam_(query, key) {
return (query.split('&').map(p => p.split('=').map(decodeURIComponent)).find(([k]) => k === key) || [])[1];
}
2. スクリプトプロパティ設定
「プロジェクト設定」 → 「スクリプトプロパティ」 に以下を追加:
LINE_CHANNEL_ACCESS_TOKEN LINEの長期トークン
TWILIO_ACCOUNT_SID SID(Account SID)
TWILIO_AUTH_TOKEN Secret(Auth Token)
TWILIO_FROM_NUMBER Twilio電話番号(例:+15017122661)
WEBAPP_BASE (後で設定)
3. デプロイ(Webhook設定)
- デプロイ → 新しいデプロイ → 「ウェブアプリ」
実行ユーザー:自分
アクセス権:全員(匿名)
-
/exec のURLをコピー
-
Webhook設定
LINE Developersページの上メニュー、「Messaging API設定」を開く。
Webhook設定 の Webhook URLに、/exec のURLをペーストする。 -
Webhookの利用 を有効にする。
-
「検証」ボタンを押す → 接続確認(200が返ればOK)
-
スクリプトプロパティ設定
GASのスクリプトプロパティ WEBAPP_BASEに、 「/exec のURL」を登録。
4. トリガー設定(自動送信)
5️⃣ LIFF フォームを作る
1. LIFFの中身を作る(コードをGitHubに作成)
① GitHubに新規リポジトリを作成し、GitHub codespaceを開き、index.html を作成。index.htmlへ以下のコードをコピペする。GitHubへpushする。
- GitHubで新規リポジトリを作成(例:customer-info-liff)
- Codespaces を開き、index.html を作成して下のコードを貼り付け → Commit & Push
git add .
git commit -m "新規"
git push origin main
LIFF(index.html)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>登録フォーム(LIFF)</title>
<script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<style>
body { font-family: system-ui, "Noto Sans JP", sans-serif; background: #f8f9fa; padding: 24px; }
.card { max-width:560px; margin:0 auto; background:#fff; border-radius:16px; box-shadow:0 8px 24px rgba(0,0,0,.1); padding:20px; }
label { display:block; margin-top:12px; font-weight:600; }
input { width:95%; padding:10px; border:1px solid #ccc; border-radius:8px; margin-top:4px; }
button { width:100%; margin-top:20px; background:#06c755; color:#fff; font-size:1rem; padding:12px; border:none; border-radius:10px; }
</style>
</head>
<body>
<div class="card">
<h1>ご登録フォーム</h1>
<label>氏名</label><input id="name" type="text" placeholder="山田 太郎">
<label>電話番号</label><input id="phone" type="tel" placeholder="09012345678">
<label>登録日</label><input id="date" type="date">
<button id="sendBtn">送信</button>
<div id="status"></div>
</div>
<script>
const LIFF_ID = 'YOUR_LIFF_ID_HERE';
async function main(){
await liff.init({liffId:LIFF_ID});
if(!liff.isLoggedIn()) return liff.login();
document.getElementById('sendBtn').onclick=async()=>{
const n=document.getElementById('name').value;
const p=document.getElementById('phone').value;
const d=document.getElementById('date').value;
const msg=`登録\n氏名:${n}\n電話:${p}\n登録日:${d}`;
await liff.sendMessages([{type:'text',text:msg}]);
alert('送信しました');
if(liff.isInClient()) liff.closeWindow();
};
}
main();
</script>
</body>
</html>
② Netlify または Vercel にデプロイ(静的サイトなのでビルド不要)。
Netlifyの場合
- https://app.netlify.com → Add new site → Import an existing project
- GitHub を選び、リポジトリを選択
- Build 設定は不要(静的サイト)/Publish directory は /
- Deploy → https://(site-name).netlify.app が発行
Vercelの場合
- https://vercel.com → Add New → Project
- GitHub を接続 → リポジトリを選択
- Framework = Other、そのまま
- https://(site-name).vercel.appが発行。
2. LIFFを作る(入れ物を作る)
- LINE Developersの新規チャネル作成 → 「LINEログイン」を選択。
- LIFF設定で Endpoint URL にデプロイで取得したURLを登録。
- Scope: chat_message.write を付与
- Allow LIFF to send messages をON。
- LIFF ID を控える
- LIFF ID を使用(URLに ?liff=LIFF_ID を付けるか、index.html 内に直書き)。
6️⃣ リッチメニュー・あいさつ設定
リッチメニューの作成と登録
- Line公式アカウント(https://www.lycbiz.com/jp/login/ )を開き、ページ左側の LINE公式アカウント の「管理画面にログイン」をクリックしページを開く。
- アカウント名一覧の中から、先ほど作成したアカウント名をクリックしページを開く。
- 左側メニューの下の方、トークルーム管理 の 「リッチメニュー」を開く。
- 右上の「作成」をクリックし、リッチメニューを新規作成する。
- タイトル:お好きな名前を入力
- 表示期間:出来るだけ長く(Max 10年くらい)
- テンプレートを選択
- アクション の タイプを 「リンク」を選択し、その下の枠(URLを入力)に LIFF URL を登録
あいさつメッセージに LIFFのリンク を記載し導線を確保
- (Line公式アカウント「管理画面にログイン」のページ)
左側メニューの下の方、トークルーム管理 の 「あいさつメッセージ」を開く。 - あいさつメッセージを編集、LIFFのリンク を記載します。
- あいさつメッセージに LIFFのリンク を記載し導線を確保
✅ まとめ
| 項目 | 内容 |
|---|---|
| Bot動作 | GAS(Webhook) |
| データ管理 | Google Sheets |
| 定期送信 | トリガー(1/3/6か月) |
| フォールバック | Twilio SMS |
| 登録UI | LIFFフォーム(Netlify/Vercel) |
| ユーザー導線 | リッチメニュー+あいさつ文 |
応用
更につかいやすくするための工夫として、以下のカスタマイズをしてみました。
【 カスタマイズ1 】
登録情報を後で修正できるようにする
GAS, LIFFのコードを、以下のコードに差し替えていただければ、使用可能です。
GAS(コード.gs)
/***** 設定(スクリプトプロパティ & シート名) *****************************/
const LINE_CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_CHANNEL_ACCESS_TOKEN");
const TWILIO_SID = PropertiesService.getScriptProperties().getProperty("TWILIO_ACCOUNT_SID");
const TWILIO_AUTH = PropertiesService.getScriptProperties().getProperty("TWILIO_AUTH_TOKEN");
const TWILIO_FROM = PropertiesService.getScriptProperties().getProperty("TWILIO_FROM_NUMBER");
const WEBAPP_BASE = PropertiesService.getScriptProperties().getProperty("WEBAPP_BASE") || '';
const TZ = 'Asia/Tokyo';
const SHEET_CUSTOMERS = 'Customers';
const SHEET_MESSAGES = 'Messages';
const ss = SpreadsheetApp.getActiveSpreadsheet();
const C_Sheet = ss.getSheetByName(SHEET_CUSTOMERS);
const M_Sheet = ss.getSheetByName(SHEET_MESSAGES);
/** 1/3/6か月の列マッピング(Customersシートの列) */
const MILESTONES = [
{ key: '1カ月目', months: 1, cols: { line:'F', engaged:'G', engaged_msg:'H', sms:'I' } },
{ key: '3カ月目', months: 3, cols: { line:'J', engaged:'K', engaged_msg:'L', sms:'M' } },
{ key: '6カ月目', months: 6, cols: { line:'N', engaged:'O', engaged_msg:'P', sms:'Q' } },
];
/***** 共通ユーティリティ ***************************************************/
function createOk_() {
return ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT);
}
function fmt_(d, pat='yyyy-MM-dd HH:mm:ss') { return Utilities.formatDate(d, TZ, pat); }
function today0_() { return new Date(Utilities.formatDate(new Date(), TZ, 'yyyy/MM/dd 00:00:00')); }
function asDate0_(v) {
if (!v) return null;
if (Object.prototype.toString.call(v) === '[object Date]') return new Date(v.getFullYear(), v.getMonth(), v.getDate());
const s = String(v).trim();
const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (m) return new Date(Number(m[1]), Number(m[2])-1, Number(m[3]));
const t = Date.parse(s);
if (isNaN(t)) return null;
const d = new Date(t);
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addMonths_(date, months) {
const d = new Date(date.getTime());
const m = d.getMonth() + months;
d.setMonth(m);
if (d.getMonth() !== ((m % 12) + 12) % 12) d.setDate(0);
return d;
}
function daysBetween_(a, b) { return Math.floor((b - a) / (1000*60*60*24)); }
function colLetterToIndex_(letter) { let n=0; for (let i=0;i<letter.length;i++) n=n*26+(letter.charCodeAt(i)-64); return n-1; }
/** ユーザーIDのマスク/復元(従来ロジック) */
function maskFromUserId_(userId) { const rest = userId.slice(0, userId.length - 6); const last6 = userId.slice(-6); return last6 + rest; }
function unmaskUserId_(userId_CC) { if (!userId_CC || userId_CC.length < 7) return userId_CC; return userId_CC.slice(6) + userId_CC.slice(0,6); }
/***** メッセージテンプレ取得 ***********************************************/
function getMessageFor_(milestoneKey) {
const last = M_Sheet.getLastRow(); if (last < 2) return null;
const values = M_Sheet.getRange(2,1,last-1,3).getValues(); // A〜C
for (const [ms, lineText, smsText] of values) {
if (String(ms).trim() === milestoneKey) return { lineText: String(lineText||''), smsText: String(smsText||'') };
}
return null;
}
function buildLineBody_(baseText, name, milestoneKey) {
const fallback = `${name} さま、${milestoneKey}のご案内です.`;
if (!baseText) return fallback;
return String(baseText).replaceAll('◯◯', name) || fallback;
}
function buildQuickReply_(milestoneKey) {
return {
items: [
{ type:'action', action:{ type:'postback', label:'変わり有りません', data:`ack=nochange&ms=${milestoneKey}`, displayText:'変わり有りません' } },
{ type:'action', action:{ type:'postback', label:'変わり有ります', data:`ack=change&ms=${milestoneKey}`, displayText:'変わり有ります' } },
]
};
}
/***** LINEユーティリティ ***************************************************/
function replyText_(replyToken, text){
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
method:"post",
headers:{ "Content-Type":"application/json; charset=UTF-8", "Authorization":"Bearer "+LINE_CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ replyToken, messages:[{ type:"text", text }] }),
muteHttpExceptions:true
});
if (res.getResponseCode() !== 200) console.error('LINE reply error', res.getResponseCode(), res.getContentText());
}
function pushMessage_(toUserId, messageObj) {
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', {
method:'post',
contentType:'application/json; charset=utf-8',
headers:{ Authorization:'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ to: toUserId, messages:[ messageObj ] }),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) console.error('LINE push error', res.getResponseCode(), res.getContentText());
}
/***** Twilio SMS ***********************************************************/
function sendSmsTwilio_(toNumber, body) {
if (!TWILIO_SID || !TWILIO_AUTH || !TWILIO_FROM) { console.error('Twilio settings missing'); return; }
const res = UrlFetchApp.fetch(`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_SID}/Messages.json`, {
method: 'post',
payload: { From: TWILIO_FROM, To: toNumber, Body: body },
muteHttpExceptions: true,
headers: { Authorization: 'Basic ' + Utilities.base64Encode(TWILIO_SID + ':' + TWILIO_AUTH) },
});
if (res.getResponseCode() >= 300) console.error('Twilio SMS failed', res.getResponseCode(), res.getContentText());
}
/***** Customers 行確保/検索 **********************************************/
function ensureRowByMaskedId_(userId_CC) {
const lastRow = C_Sheet.getLastRow();
const n = Math.max(0, lastRow - 1);
const dat = n ? C_Sheet.getRange(2, 3, n, 1).getValues().flat() : [];
const idx = dat.indexOf(userId_CC);
if (idx !== -1) return idx + 2;
const inputRow = lastRow + 1;
const C_number = "CUST-" + Utilities.formatString("%04d", lastRow);
C_Sheet.getRange(inputRow, 1).setValue(C_number);
C_Sheet.getRange(inputRow, 3).setValue(userId_CC);
return inputRow;
}
/***** Engaged 記録 *********************************************************/
function markEngagedByMaskedId_(userId_CC, msKey /* optional */) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
for (let i=0; i<values.length; i++) {
const row = values[i];
if (String(row[2]||'').trim() === userId_CC) {
const stamp = fmt_(new Date());
if (msKey) {
const msObj = MILESTONES.find(m => m.key === msKey);
if (msObj) { const idxEng = colLetterToIndex_(msObj.cols.engaged); C_Sheet.getRange(i+2, idxEng+1).setValue(stamp); }
return;
}
const ordered = MILESTONES.slice().reverse();
for (const m of ordered) {
const idxLine = colLetterToIndex_(m.cols.line);
const idxEng = colLetterToIndex_(m.cols.engaged);
if (row[idxLine] && !row[idxEng]) { C_Sheet.getRange(i+2, idxEng+1).setValue(stamp); return; }
}
return;
}
}
}
function setEngagedTimestamp_(maskedId, milestoneKey, dateObj) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
const ms = MILESTONES.find(m => m.key === milestoneKey); if (!ms) return;
const idxEng = colLetterToIndex_(ms.cols.engaged);
for (let i=0; i<values.length; i++) if (String(values[i][2]||'').trim() === maskedId) { C_Sheet.getRange(i+2, idxEng+1).setValue(fmt_(dateObj)); return; }
}
function setEngagedMessage_(maskedId, milestoneKey, text) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
const ms = MILESTONES.find(m => m.key === milestoneKey); if (!ms) return;
const idxMsg = colLetterToIndex_(ms.cols.engaged_msg);
for (let i=0; i<values.length; i++) if (String(values[i][2]||'').trim() === maskedId) { C_Sheet.getRange(i+2, idxMsg+1).setValue(text); return; }
}
/***** 入力待ちフラグ ******************************************************/
function setAwaitingChangeFlag_(maskedId, milestoneKey) { PropertiesService.getScriptProperties().setProperty('AWAIT_' + maskedId, milestoneKey); }
function getAwaitingChangeFlag_(maskedId) { return PropertiesService.getScriptProperties().getProperty('AWAIT_' + maskedId) || null; }
function clearAwaitingChangeFlag_(maskedId) { PropertiesService.getScriptProperties().deleteProperty('AWAIT_' + maskedId); }
/***** 1)登録日+1/3/6か月 当日→ LINE送信 *******************************/
function checkAndSendLineDaily() {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues();
const today0 = today0_();
for (let i=0;i<range.length;i++) {
const row = range[i];
const name = String(row[1]||'').trim() || 'お客さま';
const maskedId = String(row[2]||'').trim();
const anchor0 = asDate0_(row[4]);
if (!maskedId || !anchor0) continue;
const lineUserId = unmaskUserId_(maskedId);
for (const ms of MILESTONES) {
const dueRaw = addMonths_(anchor0, ms.months);
const due0 = new Date(dueRaw.getFullYear(), dueRaw.getMonth(), dueRaw.getDate());
const idxLine = colLetterToIndex_(ms.cols.line);
if (row[idxLine]) continue;
if (daysBetween_(due0, today0) === 0) {
const tmpl = getMessageFor_(ms.key);
const lineBody = buildLineBody_(tmpl && tmpl.lineText, name, ms.key);
pushMessage_(lineUserId, { type:'text', text: lineBody, quickReply: buildQuickReply_(ms.key) });
C_Sheet.getRange(i+2, idxLine+1).setValue(fmt_(new Date()));
}
}
}
}
/***** 2) 7日無反応 → SMS ***********************************************/
function checkAndSendSmsFallbackDaily() {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues();
const now = new Date();
for (let i=0;i<range.length;i++) {
const row = range[i];
const name = String(row[1]||'').trim() || 'お客さま';
const phone = String(row[3]||'').trim();
if (!phone) continue;
for (const ms of MILESTONES) {
const idxLine = colLetterToIndex_(ms.cols.line);
const idxEng = colLetterToIndex_(ms.cols.engaged);
const idxSms = colLetterToIndex_(ms.cols.sms);
const lineSentAt = row[idxLine] ? new Date(row[idxLine]) : null;
const engagedAt = row[idxEng] ? new Date(row[idxEng]) : null;
const smsSentAt = row[idxSms] ? new Date(row[idxSms]) : null;
if (!lineSentAt || engagedAt || smsSentAt) continue;
const elapsed = daysBetween_(asDate0_(lineSentAt), asDate0_(now));
if (elapsed >= 7) {
const tmpl = getMessageFor_(ms.key);
const smsBody = (tmpl && tmpl.smsText ? tmpl.smsText : `${name}さま、LINEのご案内の再送です(${ms.key})`).replaceAll('◯◯', name);
sendSmsTwilio_(phone, smsBody);
C_Sheet.getRange(i+2, idxSms+1).setValue(fmt_(new Date()));
}
}
}
}
/***** テキスト解析:登録 新規/修正 ****************************************/
/** "登録 新規/修正" 形式を解析して {mode, name?, phone?, date?} を返す */
function parseRegisterMessage_(text) {
const lines = String(text || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length === 0) return null;
const header = lines[0]; // 例: 登録 新規 / 登録 修正
if (!/^登録/.test(header)) return null;
let mode = null;
if (/新規/.test(header)) mode = 'new';
else if (/修正/.test(header)) mode = 'update';
// 2行目以降の "氏名:xxx" / "電話:xxx" / "登録日:YYYY/MM/DD"
let name = null, phone = null, date = null;
for (let i = 1; i < lines.length; i++) {
const l = lines[i];
const mName = l.match(/^氏名:(.+)$/);
const mPhone= l.match(/^電話:(.+)$/);
const mDate = l.match(/^登録日:(.+)$/);
if (mName) name = mName[1].trim();
if (mPhone) phone = mPhone[1].trim();
if (mDate) date = mDate[1].trim();
}
return { mode, name, phone, date };
}
/** 電話番号をE.164へ(先頭0なら+81へ、+始まりはそのまま) */
function toE164_(v) {
if (!v) return '';
const s = String(v).trim();
if (s.startsWith('+')) return s;
if (s.startsWith('0')) return '+81' + s.substring(1);
return s;
}
/***** Webhook(登録/修正/返信/ポストバック) *****************************/
function doPost(e) {
try {
const body = e.postData && e.postData.contents ? e.postData.contents : '';
const params = e.parameter || {};
// Twilio Status Callback (任意)
if (params && params.MessageSid && params.MessageStatus) {
console.log('Twilio Status:', params.MessageSid, params.MessageStatus, params.To, params.ErrorCode || '');
return createOk_();
}
if (!body) return createOk_();
const json = JSON.parse(body);
if (!json.events || !Array.isArray(json.events)) return createOk_();
json.events.forEach(ev => {
const userId = ev.source && ev.source.userId;
const replyToken = ev.replyToken || null;
// フォロー時:行確保
if (ev.type === 'follow') {
if (userId) ensureRowByMaskedId_(maskFromUserId_(userId));
return;
}
// ポストバック(クイックリプライ)
if (ev.type === 'postback') {
const data = ev.postback && ev.postback.data || '';
const msKey = getQueryParam_(data, 'ms');
const ack = getQueryParam_(data, 'ack');
if (userId && msKey) {
const maskedId = maskFromUserId_(userId);
setEngagedTimestamp_(maskedId, msKey, new Date());
if (ack === 'nochange') {
setEngagedMessage_(maskedId, msKey, '変わり有りません');
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
} else if (ack === 'change') {
setAwaitingChangeFlag_(maskedId, msKey);
if (replyToken) replyText_(replyToken, 'ご返信ありがとうございます。\nどのような変化がありましたか?ご自由にご記入ください。');
}
}
return;
}
// メッセージ
if (ev.type === 'message') {
const msg = ev.message || {};
if (!userId || msg.type !== 'text') return;
const text = msg.text || '';
const maskedId = maskFromUserId_(userId);
const inputRow = ensureRowByMaskedId_(maskedId);
// 「変わり有ります」入力待ちフラグ対応
const awaitingMsKey = getAwaitingChangeFlag_(maskedId);
if (awaitingMsKey) {
setEngagedMessage_(maskedId, awaitingMsKey, text);
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
clearAwaitingChangeFlag_(maskedId);
return;
}
// 新形式:「登録 新規/修正」対応
const parsed = parseRegisterMessage_(text);
if (parsed && (parsed.mode === 'new' || parsed.mode === 'update')) {
// 共通:存在する項目だけ取り出し
const name = parsed.name && parsed.name.trim() ? parsed.name.trim() : null;
const phone = parsed.phone && parsed.phone.trim() ? toE164_(parsed.phone.trim()) : null;
const date = parsed.date && parsed.date.trim() ? parsed.date.trim() : null;
if (parsed.mode === 'new') {
// これまで同様:3項目とも記録(LIFFで必須担保)
if (name) C_Sheet.getRange(inputRow, 2).setValue(name);
if (phone) C_Sheet.getRange(inputRow, 4).setValue(phone);
if (date) C_Sheet.getRange(inputRow, 5).setValue(date);
if (replyToken) replyText_(replyToken, "ご登録ありがとうございます。");
} else { // 修正:入力があった項目のみ上書き
if (name) C_Sheet.getRange(inputRow, 2).setValue(name);
if (phone) C_Sheet.getRange(inputRow, 4).setValue(phone);
if (date) C_Sheet.getRange(inputRow, 5).setValue(date);
if (replyToken) replyText_(replyToken, "修正を受け付けました。ありがとうございました。");
}
return;
}
// 旧形式:「登録\n氏名:〜」にも後方互換で対応
if (text.includes('登録')) {
const user_name = extractText(text, "氏名:") || extractText(text, "お名前:") || '';
const user_phone = (extractText(text, "電話:") || '').trim();
const medDate = (extractText(text, "登録日:") || extractText(text, "登録日:") || '').trim();
if (user_name) C_Sheet.getRange(inputRow, 2).setValue(user_name.trim());
if (user_phone) C_Sheet.getRange(inputRow, 4).setValue(toE164_(user_phone));
if (medDate) C_Sheet.getRange(inputRow, 5).setValue(medDate);
if (replyToken) replyText_(replyToken, "ご登録ありがとうございます。");
return;
}
// その他テキスト=エンゲージ扱い(保険)
markEngagedByMaskedId_(maskedId, null);
if (replyToken) replyText_(replyToken, "ご用件は何でしょうか。");
}
});
return createOk_();
} catch (err) {
console.error(err);
return createOk_();
}
}
/***** doGet(未使用) *****************************************************/
function doGet(e) { return createOk_(); }
/***** トリガー作成 *********************************************************/
function createDailyTriggers() {
ScriptApp.newTrigger('checkAndSendLineDaily').timeBased().atHour(9).nearMinute(0).inTimezone(TZ).everyDays(1).create();
ScriptApp.newTrigger('checkAndSendSmsFallbackDaily').timeBased().atHour(9).nearMinute(10).inTimezone(TZ).everyDays(1).create();
}
/***** ヘルパ ***************************************************************/
function extractText(text, targetString) {
var start = text.indexOf(targetString); if (start === -1) return '';
var rest = text.substring(start + targetString.length);
var nl = rest.indexOf("\n");
return (nl === -1) ? rest : rest.substring(0, nl);
}
function getQueryParam_(query, key) {
return (query.split('&').map(p => p.split('=').map(decodeURIComponent)).find(([k]) => k === key) || [])[1];
}
LIFF(index.html)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ご登録フォーム(LIFF)</title>
<script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; margin: 0; padding: 24px; background: #f8f9fa; }
.card { max-width: 560px; margin: 0 auto; padding: 20px; border-radius: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.08); background: #fff; }
h1 { font-size: 1.25rem; margin: 0 0 12px; }
.row { margin: 10px 0; }
.mode { display:flex; gap:16px; align-items:center; }
label { display:block; font-size:.95rem; margin:10px 0 6px; }
input[type="text"], input[type="tel"], input[type="date"] { width:95%; padding:12px 14px; border:1px solid #d0d7de; border-radius:10px; font-size:1rem; box-sizing:border-box; }
.hint { font-size:.9rem; color:#6b7280; margin-top:6px; white-space:pre-line; }
.error { color:#b91c1c; font-size:.9rem; margin-top:8px; }
.success { color:#065f46; font-size:.95rem; margin-top:10px; }
button { width:100%; margin-top:16px; padding:14px 16px; font-size:1rem; border:none; border-radius:12px; background:#06c755; color:#fff; font-weight:700; cursor:pointer; }
button:disabled { opacity:.55; cursor:not-allowed; }
</style>
</head>
<body>
<div class="card">
<h1>ご登録フォーム</h1>
<div class="row mode" role="radiogroup" aria-label="登録モード">
<label><input type="radio" name="mode" value="new" checked> 新規</label>
<label><input type="radio" name="mode" value="update"> 修正</label>
</div>
<div id="modeNotice" class="hint">下の情報を全て記載してください。</div>
<label for="name">氏名</label>
<input id="name" type="text" placeholder="山田 太郎" autocomplete="name" />
<label for="phone">電話番号</label>
<input id="phone" type="tel" placeholder="09012345678" inputmode="numeric" />
<label for="date">登録日</label>
<input id="date" type="date" />
<button id="sendBtn" disabled>送信</button>
<div id="status" class=""></div>
</div>
<script>
const PARAM_LIFF_ID = new URL(location.href).searchParams.get('liff');
const LIFF_ID = PARAM_LIFF_ID || 'YOUR_LIFF_ID_HERE';
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const nameEl = $('#name');
const phoneEl = $('#phone');
const dateEl = $('#date');
const statusEl = $('#status');
const modeNotice = $('#modeNotice');
const sendBtn = $('#sendBtn');
function formatDateSlash(ymd) {
if (!ymd) return '';
const [y,m,d] = ymd.split('-');
return `${y}/${m}/${d}`;
}
function currentMode() {
const el = document.querySelector('input[name="mode"]:checked');
return el ? el.value : 'new';
}
function validate() {
const mode = currentMode();
const name = nameEl.value.trim();
const phone = phoneEl.value.replace(/[^\d+]/g, '').trim();
const date = dateEl.value;
const errs = [];
if (mode === 'new') {
// 全て必須
if (!name) errs.push('氏名を入力してください。');
if (!phone || (!/^\d{10,11}$/.test(phone) && !/^\+?\d{8,15}$/.test(phone))) {
errs.push('電話番号を正しく入力してください(ハイフン無し)。');
}
if (!date) errs.push('登録日を選択してください。');
if (errs.length) return { ok:false, errs };
return { ok:true, name, phone, date };
} else {
// いずれか1つ以上
const hasName = !!name;
const hasPhone = !!phone;
const hasDate = !!date;
if (!hasName && !hasPhone && !hasDate) {
errs.push('修正する項目を1つ以上入力してください。');
return { ok:false, errs };
}
// phoneが入力されている場合は形式チェック
if (hasPhone && (!/^\d{10,11}$/.test(phone) && !/^\+?\d{8,15}$/.test(phone))) {
errs.push('電話番号を正しく入力してください(ハイフン無し)。');
return { ok:false, errs };
}
return { ok:true, name:hasName?name:null, phone:hasPhone?phone:null, date:hasDate?date:null };
}
}
function buildMessage(v) {
const mode = currentMode();
if (mode === 'new') {
return `登録 新規\n氏名:${v.name}\n電話:${v.phone}\n登録日:${formatDateSlash(v.date)}`;
} else {
const lines = ['登録 修正'];
if (v.name) lines.push(`氏名:${v.name}`);
if (v.phone) lines.push(`電話:${v.phone}`);
if (v.date) lines.push(`登録日:${formatDateSlash(v.date)}`);
return lines.join('\n');
}
}
function updateModeNotice() {
const mode = currentMode();
modeNotice.textContent = (mode === 'new') ? '下の情報を全て記載してください。' : '修正した情報へ記載してください。';
}
// 入力監視して送信ボタンの活性/非活性を切り替え
function reevaluate() {
const v = validate();
if (v.ok) {
statusEl.className = '';
statusEl.textContent = '';
sendBtn.disabled = false;
} else {
statusEl.className = 'error';
statusEl.textContent = v.errs.join(' ');
sendBtn.disabled = true;
}
}
async function main() {
await liff.init({ liffId: LIFF_ID });
if (!liff.isLoggedIn()) { liff.login(); return; }
// 初期
updateModeNotice();
reevaluate();
// イベント
$$('.mode input[name="mode"]').forEach(r => r.addEventListener('change', ()=>{
updateModeNotice();
reevaluate();
}));
[nameEl, phoneEl, dateEl].forEach(el => el.addEventListener('input', reevaluate));
sendBtn.addEventListener('click', async ()=>{
const v = validate();
if (!v.ok) { reevaluate(); return; }
const text = buildMessage(v);
try {
sendBtn.disabled = true;
statusEl.className = '';
statusEl.textContent = '送信中…';
await liff.sendMessages([{ type:'text', text }]);
statusEl.className = 'success';
statusEl.textContent = '送信しました。画面を閉じても大丈夫です。';
if (liff.isInClient()) setTimeout(()=>liff.closeWindow(), 800);
} catch (e) {
console.error(e);
statusEl.className = 'error';
statusEl.textContent = '送信に失敗しました。権限/友だち追加/LIFF設定をご確認ください。';
} finally {
sendBtn.disabled = false;
}
});
}
main().catch(console.error);
</script>
</body>
</html>
【 カスタマイズ2 】
問い合わせに対し応答できるようにする
- Difyと連携し、ナレッジデータを元にLLMが出した回答を返信する。
- 質問内容と回答はスプレッドシートに記載する。
1. Difyを作る
Difyのフローを作る
今回は、チャットフローで作成しています。
Difyの上メニュー内「スタジオ」をクリックしページを開く。
右上の「最初から作成」をクリック。

アプリタイプを選択:チャットフロー
アプリのアイコンと名前:お好きな名前を記入

以下のフローを作成する
開始ノードとLLMノードの間の「+」をクリックし、知識検索ノードを挿入する。

ナレッジデータを作る
下の文章をコピーして、メモ帳などにペースト、「Customer_Q&A.txt」などと名前を付けて保存する。
テキストファイル(Customer_Q&A.txt)
## Q1. 営業時間はいつですか?
営業時間は、平日9:00〜12:30、15:00〜18:30、土曜は9:00〜13:00です。日曜・祝日は休みです。
---
## Q2. 予約は必要ですか?
予約優先制です。待ち時間が発生する場合がありますので、事前予約をおすすめします。
---
## Q3. 何を売っていますか?
オーダーメイド品の他、一般的な生活用品、生鮮食品、書籍などを取りそろえています。
---
## Q4. 支払い方法は何がありますか?
現金、各種クレジットカード、交通系ICカード、QRコード決済(PayPayなど)がご利用いただけます。
---
## Q5. 初めて行く際に必要なものは何ですか?
特にありません。初回来店時にメンバーカードを作成致しますので、初回以降はご持参ください。
---
## Q6. 駐車場はありますか?
はい、20台分の専用駐車場があります。満車の場合は近隣のコインパーキングをご利用ください。
---
## Q7. 発熱や風邪症状がありますが、行っても良いですか?
はい、大丈夫です。感染予防の観点から、風邪症状のある場合、必ずマスク着用をお願い致します。当社職員もマスクを着用致しますのでご了解ください。
Difyの上メニュー内「ナレッジ」をクリックしページを開く。
右上の「+ナレッジベースを作成」をクリック。

データソース:「テキストファイルからインポート」
テキストファイルをアップロードの枠にテキストファイルをドラッグアンドドロップ

チャンク設定
チャンク拡張子:「---」へ書き換える
その他はそのまま変更なし。
インデックス方法
高品質(変更なし)
埋め込みモデル
お好きなものを選択
geminiの場合は、「gemini-embedding-exp-03-07」
知識検索ノードへナレッジデータを登録
検索変数:開始/{x}sys.query
ナレッジベース:Customer_Q&A.txt(先ほど作成したナレッジベース)

LLMノードの設定
AIモデル:お好きなモデルを選択
コンテキスト:知識検索/{x}result
SYSTEM:以下のプロンプトを記入
LLMのプロンプト
## 役割
- あなたは会社の受付です。
- ユーザーからの質問に対して、会社の情報を元に返答してください。
- コンテキストに基づいて回答してください。
{{#context#}}
## 制約事項
- ユーザーが不快に思う返信は禁止です。
2. スプレッドシートに「Q&A」シート作成、設定
新たに「Q&A」シートを作成、A1セルに「質問」、B1セルに「回答」と入力。
3. GASの設定、書き換え
スクリプトプロパティ設定 追加
「プロジェクト設定」 → 「スクリプトプロパティ」 に以下を追加:
DIFY_API_KEY Difyのフローで発行したAPI key
DIFY_CHAT_URL
Difyのフローが、
chatの場合:https://api.dify.ai/v1/chat-messages
workflowの場合:https://api.dify.ai/v1/workflows/{workflow_id}/run
以下のコードに差し替え(GAS)
GAS(コード.gs)
/***** 設定(スクリプトプロパティ & シート名) *****************************/
const LINE_CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_CHANNEL_ACCESS_TOKEN");
const TWILIO_SID = PropertiesService.getScriptProperties().getProperty("TWILIO_ACCOUNT_SID");
const TWILIO_AUTH = PropertiesService.getScriptProperties().getProperty("TWILIO_AUTH_TOKEN");
const TWILIO_FROM = PropertiesService.getScriptProperties().getProperty("TWILIO_FROM_NUMBER");
const DIFY_API_KEY = PropertiesService.getScriptProperties().getProperty("DIFY_API_KEY"); // ← Chat App の App API Key
const TZ = 'Asia/Tokyo';
const SHEET_CUSTOMERS = 'Customers';
const SHEET_MESSAGES = 'Messages';
const SHEET_QA = 'Q&A';
const ss = SpreadsheetApp.getActiveSpreadsheet();
const C_Sheet = ss.getSheetByName(SHEET_CUSTOMERS) || ss.insertSheet(SHEET_CUSTOMERS);
const M_Sheet = ss.getSheetByName(SHEET_MESSAGES) || ss.insertSheet(SHEET_MESSAGES);
/** 1/3/6か月の列マッピング(Customersシートの列)
* A:customer_id, B:name, C:line_user_id_masked, D:phone(E.164), E:anchor_date
* F:I=1か月, J:M=3か月, N:Q=6か月
*/
const MILESTONES = [
{ key: '1カ月目', months: 1, cols: { line:'F', engaged:'G', engaged_msg:'H', sms:'I' } },
{ key: '3カ月目', months: 3, cols: { line:'J', engaged:'K', engaged_msg:'L', sms:'M' } },
{ key: '6カ月目', months: 6, cols: { line:'N', engaged:'O', engaged_msg:'P', sms:'Q' } },
];
/***** 共通ユーティリティ ***************************************************/
function createOk_() { return ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT); }
function fmt_(d, pat='yyyy-MM-dd HH:mm:ss') { return Utilities.formatDate(d, TZ, pat); }
function today0_() { return new Date(Utilities.formatDate(new Date(), TZ, 'yyyy/MM/dd 00:00:00')); }
function asDate0_(v) {
if (!v) return null;
if (Object.prototype.toString.call(v) === '[object Date]') return new Date(v.getFullYear(), v.getMonth(), v.getDate());
const s = String(v).trim();
const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (m) return new Date(+m[1], +m[2]-1, +m[3]);
const t = Date.parse(s); if (isNaN(t)) return null;
const d = new Date(t); return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addMonths_(date, months) {
const d = new Date(date.getTime()); const m = d.getMonth() + months;
d.setMonth(m); if (d.getMonth() !== ((m % 12 + 12) % 12)) d.setDate(0); // 月末補正
return d;
}
function daysBetween_(a, b) { return Math.floor((b - a) / (1000*60*60*24)); }
function colLetterToIndex_(letter) { let n=0; for (let i=0;i<letter.length;i++) n=n*26+(letter.charCodeAt(i)-64); return n-1; }
/** ユーザーIDのマスク/復元(従来ロジック) */
function maskFromUserId_(userId) { const rest = userId.slice(0, userId.length - 6); const last6 = userId.slice(-6); return last6 + rest; }
function unmaskUserId_(userId_CC) { if (!userId_CC || userId_CC.length < 7) return userId_CC; return userId_CC.slice(6) + userId_CC.slice(0,6); }
/***** メッセージテンプレ取得(Messages!A:C = キー/LINE文面/SMS文面) *******/
function getMessageFor_(milestoneKey) {
const last = M_Sheet.getLastRow(); if (last < 2) return null;
const values = M_Sheet.getRange(2,1,last-1,3).getValues(); // A〜C
for (const [ms, lineText, smsText] of values) {
if (String(ms).trim() === milestoneKey) return { lineText: String(lineText||''), smsText: String(smsText||'') };
}
return null;
}
function buildLineBody_(baseText, name, milestoneKey) {
const fallback = `${name} さま、${milestoneKey}のご案内です。`;
if (!baseText) return fallback;
return String(baseText).replaceAll('◯◯', name) || fallback;
}
function buildQuickReply_(milestoneKey) {
return {
items: [
{ type:'action', action:{ type:'postback', label:'変わり有りません', data:`ack=nochange&ms=${milestoneKey}`, displayText:'変わり有りません' } },
{ type:'action', action:{ type:'postback', label:'変わり有ります', data:`ack=change&ms=${milestoneKey}`, displayText:'変わり有ります' } },
]
};
}
/***** LINEユーティリティ ***************************************************/
function replyText_(replyToken, text){
const trimmed = (text || '').slice(0, 1900); // LINE上限対策
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
method:"post",
headers:{ "Content-Type":"application/json; charset=UTF-8", "Authorization":"Bearer "+LINE_CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ replyToken, messages:[{ type:"text", text: trimmed }] }),
muteHttpExceptions:true
});
if (res.getResponseCode() !== 200) console.error('LINE reply error', res.getResponseCode(), res.getContentText());
}
function pushMessage_(toUserId, messageObj) {
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', {
method:'post',
contentType:'application/json; charset=utf-8',
headers:{ Authorization:'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN },
payload: JSON.stringify({ to: toUserId, messages:[ messageObj ] }),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) console.error('LINE push error', res.getResponseCode(), res.getContentText());
}
/***** Twilio SMS ***********************************************************/
function sendSmsTwilio_(toNumber, body) {
if (!TWILIO_SID || !TWILIO_AUTH || !TWILIO_FROM) { console.error('Twilio settings missing'); return; }
const res = UrlFetchApp.fetch(`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_SID}/Messages.json`, {
method: 'post',
payload: { From: TWILIO_FROM, To: toNumber, Body: body },
muteHttpExceptions: true,
headers: { Authorization: 'Basic ' + Utilities.base64Encode(TWILIO_SID + ':' + TWILIO_AUTH) },
});
if (res.getResponseCode() >= 300) console.error('Twilio SMS failed', res.getResponseCode(), res.getContentText());
}
/***** Customers 行確保/検索 **********************************************/
function ensureRowByMaskedId_(userId_CC) {
const lastRow = C_Sheet.getLastRow();
const n = Math.max(0, lastRow - 1);
const dat = n ? C_Sheet.getRange(2, 3, n, 1).getValues().flat() : [];
const idx = dat.indexOf(userId_CC);
if (idx !== -1) return idx + 2;
const inputRow = lastRow + 1;
const C_number = "CUST-" + Utilities.formatString("%04d", lastRow);
C_Sheet.getRange(inputRow, 1).setValue(C_number);
C_Sheet.getRange(inputRow, 3).setValue(userId_CC);
return inputRow;
}
/***** Engaged 記録 *********************************************************/
function markEngagedByMaskedId_(userId_CC, msKey /* optional */) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
for (let i=0; i<values.length; i++) {
const row = values[i];
if (String(row[2]||'').trim() === userId_CC) {
const stamp = fmt_(new Date());
if (msKey) {
const msObj = MILESTONES.find(m => m.key === msKey);
if (msObj) { const idxEng = colLetterToIndex_(msObj.cols.engaged); C_Sheet.getRange(i+2, idxEng+1).setValue(stamp); }
return;
}
const ordered = MILESTONES.slice().reverse();
for (const m of ordered) {
const idxLine = colLetterToIndex_(m.cols.line);
const idxEng = colLetterToIndex_(m.cols.engaged);
if (row[idxLine] && !row[idxEng]) { C_Sheet.getRange(i+2, idxEng+1).setValue(stamp); return; }
}
return;
}
}
}
function setEngagedTimestamp_(maskedId, milestoneKey, dateObj) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
const ms = MILESTONES.find(m => m.key === milestoneKey); if (!ms) return;
const idxEng = colLetterToIndex_(ms.cols.engaged);
for (let i=0; i<values.length; i++) if (String(values[i][2]||'').trim() === maskedId) { C_Sheet.getRange(i+2, idxEng+1).setValue(fmt_(dateObj)); return; }
}
function setEngagedMessage_(maskedId, milestoneKey, text) {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const values = C_Sheet.getRange(2,1,last-1,17).getValues();
const ms = MILESTONES.find(m => m.key === milestoneKey); if (!ms) return;
const idxMsg = colLetterToIndex_(ms.cols.engaged_msg);
for (let i=0; i<values.length; i++) if (String(values[i][2]||'').trim() === maskedId) { C_Sheet.getRange(i+2, idxMsg+1).setValue(text); return; }
}
/***** 入力待ちフラグ(「変わり有ります」本文待ち) *************************/
function setAwaitingChangeFlag_(maskedId, milestoneKey) { PropertiesService.getScriptProperties().setProperty('AWAIT_' + maskedId, milestoneKey); }
function getAwaitingChangeFlag_(maskedId) { return PropertiesService.getScriptProperties().getProperty('AWAIT_' + maskedId) || null; }
function clearAwaitingChangeFlag_(maskedId) { PropertiesService.getScriptProperties().deleteProperty('AWAIT_' + maskedId); }
/***** 1) 登録日+1/3/6か月 当日→ LINE送信 *******************************/
function checkAndSendLineDaily() {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues();
const today0 = today0_();
for (let i=0;i<range.length;i++) {
const row = range[i];
const name = String(row[1]||'').trim() || 'お客さま';
const maskedId = String(row[2]||'').trim();
const anchor0 = asDate0_(row[4]);
if (!maskedId || !anchor0) continue;
const lineUserId = unmaskUserId_(maskedId);
for (const ms of MILESTONES) {
const dueRaw = addMonths_(anchor0, ms.months);
const due0 = new Date(dueRaw.getFullYear(), dueRaw.getMonth(), dueRaw.getDate());
const idxLine = colLetterToIndex_(ms.cols.line);
if (row[idxLine]) continue;
if (daysBetween_(due0, today0) === 0) {
const tmpl = getMessageFor_(ms.key);
const lineBody = buildLineBody_(tmpl && tmpl.lineText, name, ms.key);
pushMessage_(lineUserId, { type:'text', text: lineBody, quickReply: buildQuickReply_(ms.key) });
C_Sheet.getRange(i+2, idxLine+1).setValue(fmt_(new Date()));
}
}
}
}
/***** 2) 7日無反応 → SMS ***********************************************/
function checkAndSendSmsFallbackDaily() {
const last = C_Sheet.getLastRow(); if (last < 2) return;
const range = C_Sheet.getRange(2,1,last-1,17).getValues();
const now = new Date();
for (let i=0;i<range.length;i++) {
const row = range[i];
const name = String(row[1]||'').trim() || 'お客さま';
const phone = String(row[3]||'').trim();
if (!phone) continue;
for (const ms of MILESTONES) {
const idxLine = colLetterToIndex_(ms.cols.line);
const idxEng = colLetterToIndex_(ms.cols.engaged);
const idxSms = colLetterToIndex_(ms.cols.sms);
const lineSentAt = row[idxLine] ? new Date(row[idxLine]) : null;
const engagedAt = row[idxEng] ? new Date(row[idxEng]) : null;
const smsSentAt = row[idxSms] ? new Date(row[idxSms]) : null;
if (!lineSentAt || engagedAt || smsSentAt) continue;
const elapsed = daysBetween_(asDate0_(lineSentAt), asDate0_(now));
if (elapsed >= 7) {
const tmpl = getMessageFor_(ms.key);
const smsBody = (tmpl && tmpl.smsText ? tmpl.smsText : `${name}さま、LINEのご案内の再送です(${ms.key})`).replaceAll('◯◯', name);
sendSmsTwilio_(phone, smsBody);
C_Sheet.getRange(i+2, idxSms+1).setValue(fmt_(new Date()));
}
}
}
}
/***** テキスト解析:登録 新規/修正 ****************************************/
function parseRegisterMessage_(text) {
const lines = String(text || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length === 0) return null;
const header = lines[0]; // 例: 登録 新規 / 登録 修正
if (!/^登録/.test(header)) return null;
let mode = null;
if (/新規/.test(header)) mode = 'new';
else if (/修正/.test(header)) mode = 'update';
// 2行目以降の "氏名:xxx" / "電話:xxx" / "登録日:YYYY/MM/DD"
let name = null, phone = null, date = null;
for (let i = 1; i < lines.length; i++) {
const l = lines[i];
const mName = l.match(/^氏名:(.+)$/);
const mPhone= l.match(/^電話:(.+)$/);
const mDate = l.match(/^登録日:(.+)$/);
if (mName) name = mName[1].trim();
if (mPhone) phone = mPhone[1].trim();
if (mDate) date = mDate[1].trim();
}
return { mode, name, phone, date };
}
/** 電話番号をE.164へ(先頭0なら+81へ、+始まりはそのまま) */
function toE164_(v) { if (!v) return ''; const s = String(v).trim(); if (s.startsWith('+')) return s; if (s.startsWith('0')) return '+81' + s.substring(1); return s; }
/***** Dify(Chat App)呼び出し:/v1/chat-messages *************************/
/**
* Chat App用API: https://api.dify.ai/v1/chat-messages
* App API Key を使用。payload は {inputs:{}, query, response_mode:"blocking", user}
* 成功: data.answer / data.output / data.message / data.data.outputs.text の順で取得
* 失敗: HTTPコードとエラー本文要約を返信&Q&A(Debug)へ保存
*/
function getDifyAnswer_(userQuery, userIdOpt) {
const apiKey = DIFY_API_KEY;
if (!apiKey) return "システム設定エラー:DifyのApp APIキーが未設定です。";
if (!userQuery || !userQuery.trim()) return "質問文が空です。";
const url = 'https://api.dify.ai/v1/chat-messages';
const payload = {
inputs: {}, // 必要なら追加の入力をここへ
query: userQuery, // Chat App はトップレベル query
response_mode: "blocking",
user: userIdOpt || "line_user"
};
// エラーメッセージ抽出
function pickErrorFromBody_(body) {
try {
const j = JSON.parse(body);
if (j?.message) return j.message;
if (j?.error?.message) return j.error.message;
if (j?.detail) {
if (typeof j.detail === 'string') return j.detail;
if (Array.isArray(j.detail) && j.detail.length) {
const first = j.detail[0];
if (typeof first === 'string') return first;
if (first?.msg) return first.msg;
}
}
return null;
} catch (_) {
return (body || '').slice(0, 200);
}
}
// 成功本文抽出
function pickTextFromData_(data) {
if (typeof data?.answer === 'string') return data.answer; // Chat Appはこれが多い
if (typeof data?.output === 'string') return data.output;
if (typeof data?.message === 'string') return data.message;
if (data?.data?.outputs?.text && typeof data.data.outputs.text === 'string') return data.data.outputs.text;
if (data?.data?.outputs && typeof data.data.outputs === 'object') {
for (const k in data.data.outputs) {
const v = data.data.outputs[k];
if (v && typeof v.text === 'string') return v.text;
if (typeof v === 'string') return v;
}
}
return null;
}
try {
const res = UrlFetchApp.fetch(url, {
method: "post",
contentType: "application/json",
headers: { Authorization: "Bearer " + apiKey },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const code = res.getResponseCode();
const body = res.getContentText();
saveDifyDebug_(userQuery, {url, payload}, code, body); // デバッグ保存
if (code >= 400) {
const msg = pickErrorFromBody_(body) || `HTTP ${code}`;
return `回答を取得できませんでした。(HTTP ${code}: ${msg})`;
}
let data;
try { data = JSON.parse(body); } catch (e) {
return "回答を取得できませんでした。(JSON parse error)";
}
const text = pickTextFromData_(data);
return text || "回答を取得できませんでした。(No text in response)";
} catch (err) {
saveDifyDebug_(userQuery, {url, payload, errorDuring:'fetch'}, -1, String(err));
return "Difyとの通信でエラーが発生しました。";
}
}
/*** Difyデバッグ保存:シート「Q&A(Debug)」 *******************************/
function saveDifyDebug_(question, meta, httpCode, rawBody) {
const name = "Q&A(Debug)"; const tz = "Asia/Tokyo";
const sh = ss.getSheetByName(name) || ss.insertSheet(name);
sh.appendRow([
Utilities.formatDate(new Date(), tz, "yyyy/MM/dd HH:mm:ss"),
question,
JSON.stringify(meta),
httpCode,
rawBody
]);
}
/** Q&Aシートに質問と回答を追記(A:質問, B:回答, C:日時) */
function recordQA_(question, answer) {
let sheet = ss.getSheetByName(SHEET_QA);
if (!sheet) sheet = ss.insertSheet(SHEET_QA);
sheet.appendRow([question, answer, Utilities.formatDate(new Date(), TZ, "yyyy/MM/dd HH:mm:ss")]);
}
/***** Webhook(登録/修正/返信/ポストバック/Q&A) **************************/
function doPost(e) {
try {
const body = e.postData && e.postData.contents ? e.postData.contents : '';
const params = e.parameter || {};
// Twilio Status Callback (任意)
if (params && params.MessageSid && params.MessageStatus) {
console.log('Twilio Status:', params.MessageSid, params.MessageStatus, params.To, params.ErrorCode || '');
return createOk_();
}
if (!body) return createOk_();
const json = JSON.parse(body);
if (!json.events || !Array.isArray(json.events)) return createOk_();
json.events.forEach(ev => {
const userId = ev.source && ev.source.userId;
const replyToken = ev.replyToken || null;
// フォロー時:行確保
if (ev.type === 'follow') {
if (userId) ensureRowByMaskedId_(maskFromUserId_(userId));
return;
}
// ポストバック(クイックリプライ)
if (ev.type === 'postback') {
const data = ev.postback && ev.postback.data || '';
const msKey = getQueryParam_(data, 'ms');
const ack = getQueryParam_(data, 'ack');
if (userId && msKey) {
const maskedId = maskFromUserId_(userId);
setEngagedTimestamp_(maskedId, msKey, new Date());
if (ack === 'nochange') {
setEngagedMessage_(maskedId, msKey, '変わり有りません');
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
} else if (ack === 'change') {
setAwaitingChangeFlag_(maskedId, msKey);
if (replyToken) replyText_(replyToken, 'ご返信ありがとうございます。\nどのような変化がありましたか?ご自由にご記入ください。');
}
}
return;
}
// メッセージ
if (ev.type === 'message') {
const msg = ev.message || {};
if (!userId || msg.type !== 'text') return;
const text = msg.text || '';
const maskedId = maskFromUserId_(userId);
const inputRow = ensureRowByMaskedId_(maskedId);
// 「変わり有ります」入力待ちフラグ対応
const awaitingMsKey = getAwaitingChangeFlag_(maskedId);
if (awaitingMsKey) {
setEngagedMessage_(maskedId, awaitingMsKey, text);
if (replyToken) replyText_(replyToken, '確認致しました。ありがとうございました。');
clearAwaitingChangeFlag_(maskedId);
return;
}
// 新形式:「登録 新規/修正」対応
const parsed = parseRegisterMessage_(text);
if (parsed && (parsed.mode === 'new' || parsed.mode === 'update')) {
const name = parsed.name && parsed.name.trim() ? parsed.name.trim() : null;
const phone = parsed.phone && parsed.phone.trim() ? toE164_(parsed.phone.trim()) : null;
const date = parsed.date && parsed.date.trim() ? parsed.date.trim() : null;
if (parsed.mode === 'new') {
if (name) C_Sheet.getRange(inputRow, 2).setValue(name);
if (phone) C_Sheet.getRange(inputRow, 4).setValue(phone);
if (date) C_Sheet.getRange(inputRow, 5).setValue(date);
if (replyToken) replyText_(replyToken, "ご登録ありがとうございます。");
} else { // 修正
if (name) C_Sheet.getRange(inputRow, 2).setValue(name);
if (phone) C_Sheet.getRange(inputRow, 4).setValue(phone);
if (date) C_Sheet.getRange(inputRow, 5).setValue(date);
if (replyToken) replyText_(replyToken, "修正を受け付けました。ありがとうございました。");
}
return;
}
// 旧形式:「登録\n氏名:〜」にも後方互換で対応
if (text.includes('登録')) {
const user_name = extractText(text, "氏名:") || extractText(text, "お名前:") || '';
const user_phone = (extractText(text, "電話:") || '').trim();
const medDate = (extractText(text, "登録日:") || extractText(text, "登録日:") || '').trim();
if (user_name) C_Sheet.getRange(inputRow, 2).setValue(user_name.trim());
if (user_phone) C_Sheet.getRange(inputRow, 4).setValue(toE164_(user_phone));
if (medDate) C_Sheet.getRange(inputRow, 5).setValue(medDate);
if (replyToken) replyText_(replyToken, "ご登録ありがとうございます。");
return;
}
// --- Q&A(Dify:Chat App) ---
if (text && text.length > 0) {
const answer = getDifyAnswer_(text, userId);
recordQA_(text, answer); // A:質問 / B:回答 / C:日時
if (replyToken) replyText_(replyToken, answer);
return;
}
// その他
markEngagedByMaskedId_(maskedId, null);
if (replyToken) replyText_(replyToken, "ご用件は何でしょうか。");
}
});
return createOk_();
} catch (err) {
console.error(err);
return createOk_();
}
}
/***** doGet(未使用) *****************************************************/
function doGet(e) { return createOk_(); }
/***** トリガー作成 *********************************************************/
function createDailyTriggers() {
ScriptApp.newTrigger('checkAndSendLineDaily').timeBased().atHour(9).nearMinute(0).inTimezone(TZ).everyDays(1).create();
ScriptApp.newTrigger('checkAndSendSmsFallbackDaily').timeBased().atHour(9).nearMinute(10).inTimezone(TZ).everyDays(1).create();
}
/***** ヘルパ ***************************************************************/
function extractText(text, targetString) {
var start = text.indexOf(targetString); if (start === -1) return '';
var rest = text.substring(start + targetString.length);
var nl = rest.indexOf("\n");
return (nl === -1) ? rest : rest.substring(0, nl);
}
function getQueryParam_(query, key) {
return (query.split('&').map(p => p.split('=').map(decodeURIComponent)).find(([k]) => k === key) || [])[1];
}
設定チェック(超重要)
バイブコーデリングを行うにあたり、以下の設定は重要です。
Chat(Chatflow)〔Dify〕 で使うなら
- DIFY_MODE = chat
- DIFY_API_KEY = App用APIキー
- DIFY_CHAT_URL は不要(設定してあっても無視します)
もしくは、https://api.dify.ai/v1/chat-messages - かつ Workflowの入力変数名が query になっていること(Dify変数とコード変数を合わせる必要があります)
Workflow〔Dify〕 で使うなら
- DIFY_MODE = workflow
- DIFY_API_KEY = Workflow用APIキー
- DIFY_CHAT_URL = https://api.dify.ai/v1/workflows/{workflow_id}/run
workflow_id は、Difyのフローを一度実行した後、「開始ノード」を開き、「最後の実行」タグのページで、
"sys.workflow_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - かつ Workflowの入力変数名が query になっていること(Dify変数とコード変数を合わせる必要があります)
📘 参考資料
LINE Developers 公式
Google Apps Script ドキュメント
Twilio API ドキュメント
LIFF API リファレンス
LINE公式アカウントの作成 / LINE Botの初め方
(https://zenn.dev/protoout/articles/16-line-bot-setup)
📘 編集後記
コードはバイブコーデリングで作成しました。
GASのコードは、テキストの指示だけではうまく生成できなかったので、オウム返しのコードを基礎に、最低限の応答が出来るところまで自分で作成し、それを作り直してもらう形にしたところ、うまくいきました。













