3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEbotからメッセージ定期送信(LINE、SMS)~LINEbotで顧客管理~

Last updated at Posted at 2025-10-08

 最近、バイブコーディング(Vibe coding)「生成AIに指示を出してコードを書いてもらうこと」を色々と試みています。

こんな仕組みを自分で実装出来ないかな?
 バイブコーディングを使えば、作れるのでは?と思い挑戦してみました。

【 LINEbotで顧客管理 】

 定期的にメッセージを送り、意見を伺う。
~ LIFFフォーム登録 → スプレッドシート管理 → 定期LINE通知&SMSフォールバックまで ~

🎯構成

  • GASでLINE Botを動かす
  • LIFFフォームで氏名/電話番号/基準日を収集
  • スプレッドシートに登録
  • 基準日の1・3・6か月後にLINEメッセージを自動送信(クイックリプライ付)
  • 7日間返信が無い場合はTwilioでSMSを自動送信
  • 返信内容と確認日時を自動的にスプレッドシートへ記録

LINEbotからメッセージ定期送信 図 ver1のコピー.png

顧客情報の登録

Qiita_01登録.gif

1カ月目LINEメッセージ 「問題なし」の返答

Qiita_02-1LINE01.gif

1カ月目LINEメッセージ 「問題あり」の返答
Qiita_02-1LINE02.gif

1カ月目SMSメッセージ
Qiita_02-2SMS.gif

作成手順

作成の流れ

1. LINEbotを作成する
2. スプレッドシートを作成する
3. SMS送信サービスTwillioを設定する
4. GASを作成する、各種設定
5. LIFFを作る
6. リッチメニューを作成。友達登録時のあいさつメッセージ、リッチメニューにLIFFのURLを登録。

🪜 作成手順

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

スクリーンショット_7-10-2025_122817_docs.google.com.jpeg

項目
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

スクリーンショット_7-10-2025_12251_docs.google.com.jpeg

項目 1カ月目 3カ月目 6カ月目
A key 1カ月目 3カ月目 6カ月目
B LINE文面 ◯◯ さま、1カ月目のご案内です。 ◯◯ さま、3カ月目のご案内です。 ◯◯ さま、6カ月目のご案内です。
C SMS文面 ◯◯さま、先日のご案内の再送です(1カ月目)。 ◯◯さま、先日のご案内の再送です(3カ月目)。 ◯◯さま、先日のご案内の再送です(6カ月目)。

3️⃣ Twilio(SMS送信サービス)設定

Twilioに新規登録&ログイン

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) を許可。:これはしなくても大丈夫)

⚠ Trial制限

  • 宛先はVerify(事前登録)済み番号のみ(Verifyしないと送れない)
  • SMS本文の先頭に「Sent from your Twilio trial account:」という文字列が自動で付く(有料化すれば消える)
  • 有料化(約$20課金)で制限解除(任意の番号に送信でき、文頭のTrialメッセージも消える)

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. トリガー設定(自動送信)

checkAndSendLineDaily  毎日8:00〜9:00**

(投与日+1/3/6ヶ月当日にLINE送信)

checkAndSendSmsFallbackDaily 毎日9:00〜10:00**

(7日無反応者にSMS送信)

5️⃣ LIFF フォームを作る

1. LIFFの中身を作る(コードをGitHubに作成)

① GitHubに新規リポジトリを作成し、GitHub codespaceを開き、index.html を作成。index.htmlへ以下のコードをコピペする。GitHubへpushする。

  1. GitHubで新規リポジトリを作成(例:customer-info-liff)
  2. 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の場合

  1. https://app.netlify.com → Add new site → Import an existing project
  2. GitHub を選び、リポジトリを選択
  3. Build 設定は不要(静的サイト)/Publish directory は /
  4. Deploy → https://(site-name).netlify.app が発行

Vercelの場合

  1. https://vercel.com → Add New → Project
  2. GitHub を接続 → リポジトリを選択
  3. Framework = Other、そのまま
  4. 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 】

登録情報を後で修正できるようにする

LIFF改良.png

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 】

問い合わせに対し応答できるようにする

LINEbotからメッセージ定期送信 図のコピー.png

カスタマイズ2.png

  • Difyと連携し、ナレッジデータを元にLLMが出した回答を返信する。
  • 質問内容と回答はスプレッドシートに記載する。

1. Difyを作る

Difyのフローを作る

今回は、チャットフローで作成しています。

Difyの上メニュー内「スタジオ」をクリックしページを開く。
右上の「最初から作成」をクリック。

アプリタイプを選択:チャットフロー
アプリのアイコンと名前:お好きな名前を記入
スクリーンショット_10-10-2025_174036_cloud.dify.ai.jpeg

以下のフローを作成する
開始ノードとLLMノードの間の「+」をクリックし、知識検索ノードを挿入する。
スクリーンショット_10-10-2025_102316_cloud.dify.ai.jpeg

ナレッジデータを作る

下の文章をコピーして、メモ帳などにペースト、「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の上メニュー内「ナレッジ」をクリックしページを開く。
右上の「+ナレッジベースを作成」をクリック。

データソース:「テキストファイルからインポート」
テキストファイルをアップロードの枠にテキストファイルをドラッグアンドドロップ
スクリーンショット_10-10-2025_17358_cloud.dify.ai.jpeg

チャンク設定
チャンク拡張子:「---」へ書き換える
その他はそのまま変更なし。

インデックス方法
高品質(変更なし)

埋め込みモデル
お好きなものを選択
geminiの場合は、「gemini-embedding-exp-03-07」

検索設定
ハイブリッド検索→ウェイト検索 を選択。
スクリーンショット_10-10-2025_101936_cloud.dify.ai.jpeg

スクリーンショット_10-10-2025_10200_cloud.dify.ai.jpeg

知識検索ノードへナレッジデータを登録

検索変数:開始/{x}sys.query
ナレッジベース:Customer_Q&A.txt(先ほど作成したナレッジベース)
スクリーンショット_10-10-2025_102149_cloud.dify.ai.jpeg

LLMノードの設定

AIモデル:お好きなモデルを選択
コンテキスト:知識検索/{x}result
SYSTEM:以下のプロンプトを記入

LLMのプロンプト
## 役割
- あなたは会社の受付です。
- ユーザーからの質問に対して、会社の情報を元に返答してください。
- コンテキストに基づいて回答してください。

{{#context#}}

## 制約事項
- ユーザーが不快に思う返信は禁止です。

スクリーンショット_10-10-2025_102255_cloud.dify.ai.jpeg

2. スプレッドシートに「Q&A」シート作成、設定

新たに「Q&A」シートを作成、A1セルに「質問」、B1セルに「回答」と入力。

スクリーンショット_10-10-2025_16424_docs.google.com.jpeg

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のコードは、テキストの指示だけではうまく生成できなかったので、オウム返しのコードを基礎に、最低限の応答が出来るところまで自分で作成し、それを作り直してもらう形にしたところ、うまくいきました。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?