🐶

GASで作ったLINE自動応答botのコード全文公開。任意の分数だけ遅延させられる機能つき

2025/03/12に公開

背景

仕事でLINE公式アカウントにて顧客管理。
公式アカウントに用意してあるキーワード自動応答機能だと即時返信しかできない。
「機械が応答してる感」を無くすために、わざと自動遅延応答させたかった。

ちなみにこの遅延応答は、LSTEPには用意されている機能だが、エルメやUTAGEには存在しないので、このコードは参考にする価値あると思います。

問題なく機能しますが、初心者がAIに聞きながら突貫で作ったので、コードの記述が多少汚いのはご勘弁を。

細かい背景事情は下記記事に

https://zenn.dev/sh102/articles/a8c8ad9f1138ed

全体構造

GASにおいて、LINEで、こちら起点で何かを送るのではなく
客からのフォローとかメッセージ受信を起点とする動作は
doPostという関数に全部まとめる必要があるとAIに教わった。

なので、各顧客のLINEユーザーIDの取得や自動応答を、doPostにまとめて同じgsファイル内にガーッと書いたら
長くなりすぎて後でメンテが大変そうなので、Claudeに相談。

余談だが
コード書くならGeminiよりClaudeのほうが全然優秀。(2025年初時点)
GASだから同じGoogleのほうが良いかと思っても
ちょっと複雑な内容になると、Geminiは普通に見落としだらけの回答が返ってきます。
2.0 Pro Experimentalだったとしても。2.0Flashとかは論外。

問題点をこっちが把握できていて
単にコード書き換え作業が面倒なときとかは、Geminiも十分戦力になるけど。



さて、doPost関連の構成をClaudeに聞いた結果
全体メイン関数としてのdoPostを置いて、その他の機能を別のファイルに置くことを提案された。

結果として以下のように分けることになった。

各ファイル(コード)構成

  1. doPost.gs:
    全体統括

  2. ユーティリティ&全体イベント振り分け.gs:
    APIのアクセストークン取得とか、LINEの実送信機能とかが置いてある。
    また、今回は
    顧客のフォローだったりスタンプ受信だったり
    「テキストメッセージ受信」以外のイベントに反応する必要はなかったので、まず「テキスト受信かどうか」を判定する関数をここにおいた。

  3. TextEventProcessors.gs:
    発生したイベントがテキスト受信なら、ユーザーID取得と、必要に応じて自動応答。

  4. AutoResponseProcessor(自動応答処理).gs:
    キーワード応答処理

コード内容

1. doPost

GAS
/**
 * Webhookからのリクエストを処理する関数
 * 
 * LINEプラットフォームからのイベントを受け取り、適切なハンドラーに振り分ける
 * 
 * @param {Object} e - Webhookイベントのデータ
 * @return {Object} 処理結果のJSONレスポンス
 */
function doPost(e) {
  const requestId = Utilities.getUuid(); // リクエストごとの一意のIDを生成
  GeneralUtils.logDebug_(`Webhook received (RequestID: ${requestId})`, e.postData.contents);

  // Webhookイベントの重複をチェック(60秒以内の同一イベントIDを無視)
  const eventData = JSON.parse(e.postData.contents);
  const webhookEventId = eventData.events[0].webhookEventId;
  const processedEventIds = CacheService.getScriptCache();

  if (processedEventIds.get(webhookEventId)) {
    GeneralUtils.logDebug_(`重複するWebhookイベントを無視 (RequestID: ${requestId}, EventID: ${webhookEventId})`);
    return ContentService.createTextOutput(JSON.stringify({ status: 'duplicate ignored' }))
      .setMimeType(ContentService.MimeType.JSON);
  }

  processedEventIds.put(webhookEventId, 'processed', 60);

  try {
    // イベントを種類別に振り分け
    const sortedEvents = distributeEvents_(e, requestId); // requestIdを渡す

    // メッセージイベントが含まれている場合、メッセージハンドラーで処理
    if (sortedEvents.message) {
      LineHandlers.MessageHandler.process_(sortedEvents.message, requestId); // requestIdを渡す
    }

    // 処理成功のレスポンスを返す
    return ContentService.createTextOutput(JSON.stringify({ status: 'success' }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    // エラー発生時はログを記録し、エラーレスポンスを返す
    GeneralUtils.logError_(`doPost (RequestID: ${requestId})`, error);
    return ContentService.createTextOutput(JSON.stringify({
      status: 'error',
      message: error.message,
      timestamp: new Date().toISOString()
    })).setMimeType(ContentService.MimeType.JSON);
  }
}

2. ユーティリティ&全体イベント振り分け

2-1. ユーティリティ系

GAS

/**
 * =====================================================
 * グループ1: LINE関連ユーティリティ
 * LINEプラットフォームとの直接的な通信を担当
 * - アクセストークン管理
 * - プロフィール情報取得
 * - メッセージ送信
 * =====================================================
 */
const LineUtils = {
  /**
   * LINE Messaging APIのアクセストークンを取得する
   * @return {string} LINE Messaging APIのアクセストークン
   * @private
   */
  getAccessToken_: function() {
    return PropertiesService.getScriptProperties().getProperty('LINE_ACCESS_TOKEN');
  },

  /**
   * LINEユーザーのプロフィール情報を取得する
   * @param {string} userId - LINEユーザーID
   * @return {Object} プロフィール情報
   * @private
   */
  getUserName_: function(userId) {
    const accessToken = this.getAccessToken_();
    const url = `https://api.line.me/v2/bot/profile/${userId}`;
    const options = {
      headers: {
        Authorization: `Bearer ${accessToken}`
      }
    };

    try {
      // LINE Profile APIにリクエストを送信
      const response = UrlFetchApp.fetch(url, options);
      if (response.getResponseCode() === 200) {
        // 正常にプロフィール情報を取得できた場合
        const json = JSON.parse(response.getContentText());
        return {
          success: true,
          displayName: json.displayName
        };
      } 
      // APIからエラーレスポンスが返ってきた場合(400系, 500系エラー)
      const errorJson = JSON.parse(response.getContentText());
      GeneralUtils.logError_('LINE Profile API error', new Error(response.getContentText()));
      return {
        success: false,
        errorCode: errorJson.code,
        errorMessage: errorJson.message
      };
    } catch (error) {
      // ネットワークエラーやパース失敗など、予期せぬエラーが発生した場合
      GeneralUtils.logError_('LINE Profile API error', error);
      return {
        success: false,
        errorMessage: error.message
      };
    }
  },

   /**
   * LINEユーザーにメッセージを送信する
   * @param {string} userId - 送信先のLINEユーザーID
   * @param {Object} messageObj - 送信するメッセージオブジェクト
   * @param {string} accessToken - LINE Messaging APIのアクセストークン
   * @private
   */
  sendMessage_: function(userId, messageObj, accessToken) {
    try {
      const url = 'https://api.line.me/v2/bot/message/push';
      const headers = {
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': `Bearer ${accessToken}`
      };

      let message;
    // messageObj.content が画像URLかどうかでメッセージタイプを判定
    if (messageObj.content.match(/\.(jpeg|jpg|gif|png)$/i) != null) { 
      // Flex Messageとして画像を送信
      message = {
        'type': 'flex',
        'altText': messageObj.altText, 
        'contents': {
          'type': 'bubble',
          'hero': {
            'type': 'image',
            'url': messageObj.content, 
            'size': 'full',
            'aspectMode': 'cover'
          }
        }
      };
    } else {
      // テキストメッセージを送信
      message = {
        'type': 'text',
        'text': messageObj.content
      };
    }

      const data = {
        'to': userId,
        'messages': [message]
      };

      const options = {
        'method': 'post',
        'headers': headers,
        'payload': JSON.stringify(data),
        'muteHttpExceptions': true
      };

      const response = UrlFetchApp.fetch(url, options);

      if (response.getResponseCode() !== 200) {
        const errorJson = JSON.parse(response.getContentText());
        GeneralUtils.logError_('LINE メッセージ送信エラー', new Error(response.getContentText()));
      }
    } catch (error) {
      GeneralUtils.logError_('LineUtils.SendMessage_', error);
    }
  }
};

2-2. 発生イベント振り分け

GAS

/**
 * =====================================================
 * グループ2: Webhook Event Handling
 * Webhookイベントの受信と処理を担当
 * =====================================================
 */

/**
 * Webhookから受信したイベントを振り分け、各種ログを記録する
 * @param {Object} e - Webhookイベントのデータ
 * @return {Object} イベントタイプごとに分類されたイベントの配列
 * @private
 */
function distributeEvents_(e) {
  GeneralUtils.logDebug_('distributeEvents_ 開始');
  
  try {
    // Webhookデータからイベント配列を取得
    const events = JSON.parse(e.postData.contents).events;    
    // 必要なシートの参照を取得
    const sheets = getRequiredSheets_();
    
    // 全てのイベントをログシートに記録
    GeneralUtils.logDebug_('logAllEvents_ 開始');
    logAllEvents_(events, sheets.allEventsSheet);  

    // イベントの種類ごとに処理を振り分ける
    const result = {};
    // メッセージイベントが含まれている場合
    if (events.some(event => event.type === 'message')) {
      // メッセージイベントのみを抽出
      result.message = events.filter(event => event.type === 'message');
    }
    
    return result;
  } catch (error) {
    GeneralUtils.logError_('distributeEvents_', error);
    throw error;
  }
} 


/**
* 必要なスプレッドシートの参照を取得する
* @return {Object} 各シートの参照を含むオブジェクト
* @private
*/
function getRequiredSheets_() {
 // スプレッドシートを開く
 const ss = SpreadsheetApp.openById('');
 // 各シートの参照を取得し、オブジェクトとして返す
 return {
   debugSheet: ss.getSheetByName('Debug'),
   allEventsSheet: ss.getSheetByName('AllEventsLog')
 };
}


/**
 * イベントを種類別に分類する
 * @param {Array} events - 分類対象のイベント配列
 * @return {Object} イベントタイプごとに分類されたオブジェクト
 * @private
 */
function categorizeEvents_(events) {
  // reduce関数を使用してイベントを種類別に分類
  return events.reduce((acc, event) => {
    // 新しいイベントタイプの場合、配列を初期化
    if (!acc[event.type]) {
      acc[event.type] = [];
    }
    // イベントを対応する配列に追加
    acc[event.type].push(event);
    return acc;
  }, {});
}


/**
* 全てのイベントをログシートに記録する
* LINEからのWebhookで受け取った各種イベントの詳細を記録
* @param {Array} events - Webhookから受信したイベントの配列
* @param {Object} allEventsSheet - AllEventsLogシートの参照
* @private
*/
function logAllEvents_(events, allEventsSheet) {
  try {
    if (events && events.length > 0) {
      // バッチ処理でログを記録
      GeneralUtils.processBatchEvents_(events, allEventsSheet);
    }
  } catch (error) {
    GeneralUtils.logError_('logAllEvents_', error);
  }
}


/**
 * LINEイベントを処理するハンドラーを定義
 * 振り分けられた各イベントの具体的な処理を実行
 */
const LineHandlers = {
  MessageHandler: {
    /**
     * メッセージイベントの処理を行う
     * 現在はテキストメッセージのみに対応
     * 
     * @param {Array} events - メッセージイベントの配列
     * @private
     */
  process_: function(events) {
    events.forEach(event => {
      // event.message が存在し、かつタイプが "text" の場合のみ処理を行う
      if (event.message && event.message.type === "text") { 
        this.handleText_(event);
      }
    });
  },

    /**
     * テキストメッセージの処理を行う
     * ユーザー情報を取得し、適切な処理にメッセージを振り分け
     * 
     * @param {Object} event - 単一のメッセージイベント
     * @private
     */
    handleText_: function(event) {
      // ユーザーのプロフィール情報を取得
      const profile = LineUtils.getUserName_(event.source.userId);
      // ユーザー情報を作成
      const userInfo = {
        userId: event.source.userId,
        messageText: event.message.text,
        timestamp: event.timestamp,
        userName: profile.success ? profile.displayName : ""
      };

      // テキストメッセージ処理に振り分け
      TextEventProcessors.processMessage_(userInfo, event); // event オブジェクトを渡す
    }
  }
};

3. TextEventProcessors

発生イベントがテキスト受信だったときに、何をすべきかを振り分ける。

やりたい処理は2つ。

  1. 自動応答(遅延&即時)


  2. ユーザーIDと各顧客名の紐づけ。
    運用方法としては
    客に電話番号を送ってもらい
    顧客管理シート上の電話番号と突合して
    「あ、このユーザーIDは鈴木さんか」みたいなことをやる。

    これに関しては、ここに詳細書くと長くなるので、記事分けます。

記事執筆中

3-1. 振り分け処理

ここでは
// 電話番号判定
const shouldProcessPhone
以降は無視で大丈夫です。

GAS
/**
* LINEから受信したテキストメッセージの処理を振り分けるモジュール
* 主に以下の2つの処理に振り分け:
* 1. キーワードに基づく自動応答
* 2. 電話番号下4桁とLINEユーザーIDの紐付け
*/
const TextEventProcessors = {
   /**
    * テキストメッセージの振り分けを実行
    * 優先順位:
    * 1. キーワードマッチング
    * 2. 電話番号処理
    */
    processMessage_: async function(userInfo, event) {
      GeneralUtils.logDebug_('TextEventProcessors start', userInfo);

      // キーワードマッチングチェック
      const matchedKeyword = await this.shouldProcessAutoResponse_(userInfo); 

      if (matchedKeyword) { 
        GeneralUtils.logDebug_('キーワード一致: AutoResponseProcessorへ振り分け');
        try {
          userInfo.matchedKeyword = matchedKeyword; // userInfoにキーワードをセット
          const autoResponseResult = await AutoResponseProcessor.process_(userInfo); // キーワードマッチしたら、ここで次の処理が走り出す
          
          return {
            handled: true,
            processor: 'キーワード応答',
            result: autoResponseResult
          };

        } catch (error) {
          GeneralUtils.logError_('キーワード処理委譲時にエラー', error);
          return {
            handled: false,
            error: error.message
          };
        }
      }

      // 電話番号処理始まると、テキストメッセージが数字の部分以外は削除されるから
      // 今後ほかの処理を追加するならこの位置

        // 電話番号判定
        const shouldProcessPhone = this.shouldProcessPhoneNumber_(userInfo);
        GeneralUtils.logDebug_('電話番号判定結果:', shouldProcessPhone);

        if (shouldProcessPhone) {
            try {
                GeneralUtils.logDebug_('PhoneNumberProcessor処理開始');
                const result = await PhoneNumberProcessor.mainPhoneProcess_(userInfo);
                GeneralUtils.logDebug_('PhoneNumberProcessor処理結果取得', result);

                if (result.handled) {
                    if (result.code === 'ALREADY_REGISTERED') {
                        GeneralUtils.logDebug_('既に登録済みユーザー: 処理終了');
                    }

                    return {
                        handled: true,
                        processor: '電話番号処理',
                        result: result
                    };
                }
            } catch (error) {
                GeneralUtils.logError_('電話番号処理委譲時にエラー', error);
                return {
                    handled: false,
                    error: error.message
                };
            }
        }

        // どちらの処理も行われなかった場合
        GeneralUtils.logDebug_('処理振り分け結果', {
            handled: false,
            reason: "キーワードにも電話番号形式にも該当せず"
        });
        return {
            handled: false,
            debugMessage: "振り分け対象となる条件にマッチしませんでした",
            source: event.source,
            type: event.type,
            message: userInfo.messageText,
            timestamp: event.timestamp
        };
    },

3-2. キーワードマッチングチェック

下記のようなシートを参照して、受信メッセージがキーワードを含むか(部分一致)確認する。
B列にあるワードが含まれていたら、同じ行のC列のgoogleドキュメントを開いて、書いてある内容を送信する。
ここでは、一旦、B列のキーワードが含まれているか否か、だけを判定させる。

スクリーンショット 2025-03-12 1.38.44.png

GAS
    // キーワードマッチングチェック
    shouldProcessAutoResponse_: async function(userInfo) {
      try {
        // 自動応答シートからキーワード列(B列)のみを取得
        const ss = SpreadsheetApp.openById(AutoResponseProcessor.CONFIG.SPREADSHEET_ID);
        const sheet = ss.getSheetByName(AutoResponseProcessor.CONFIG.SHEET_NAMES.AUTO_RESPONSE);
        const keywords = sheet.getRange(2, GeneralUtils.columnToNumber_(AutoResponseProcessor.CONFIG.COLUMN_NAMES.KEYWORD), sheet.getLastRow() - 1).getValues().flat();

        // ユーザーのメッセージがキーワードのいずれかを含むか判定
        const matchedKeyword = keywords.find(keyword => userInfo.messageText.includes(keyword));

        GeneralUtils.logDebug_('shouldProcessAutoResponse_ - キーワードマッチ判定結果', { matched: Boolean(matchedKeyword), keyword: matchedKeyword });

        // マッチしたキーワードを返す
        return matchedKeyword || null; // マッチしたキーワードがあればそれを返し、なければnullを返す
      } catch (error) {
        GeneralUtils.logError_('shouldProcessAutoResponse_ - エラー発生', error);
        return null; // エラー発生時はnullを返す
      }
    },

なお
Claudeは、意識高い感じのコードを書きたがるクセがあります。

今回も

GAS
// キーワードマッチングチェック
const matchedKeyword = await this.shouldProcessAutoResponse_(userInfo); 

ここで
変数宣言&分離された関数の実行を、同時にやったりせず
自動応答シートからキーワード列(B列)を取得する処理をそのまま書けばいいだけなのに、わざわざ関数を分離されちゃいました。

4. 自動応答機能!(この記事の本題)

お待たせしました。
ここからがこの記事の本題です。


顧客が送信したメッセージに、応答すべきキーワードが入っていたら、この4つ目のgsファイル内の関数たちが走り始めます。

GAS
/**
* =====================================================
* LINE遅延応答Bot - AutoResponseProcessor
*
* 主な機能:
* - キーワードに基づく自動応答
* - 即時応答と遅延応答の分岐処理
* - メッセージキュー処理
* - 定期メンテナンス
* - 応答済みフラグ管理
* - プレースホルダー置換
* =====================================================
*/

const AutoResponseProcessor = {
  
  CONFIG: {
    SPREADSHEET_ID: '',
    SHEET_NAMES: {
      AUTO_RESPONSE: '自動応答シート',  // 自動応答の設定用シート名
      UNIFIED: '顧客管理シート'            // 顧客情報管理シートm
    },
    COLUMN_NAMES: {
      KEYWORD: 'B',      // キーワード列(自動応答シート)
      RESPONSE1: 'C',    // 送信内容1
      ALT_TEXT_1:'D',     // 送信内容1の代替テキスト
      RESPONSE2: 'E',    // 送信内容2
      ALT_TEXT_2: 'F',      // 送信内容2の代替テキスト
      
      DELAY: 'G',        // 遅延時間(分)列(自動応答シート)
      FLAG_COLUMN: 'H',  // フラグを立てる列を指定する列(自動応答シート)

      USER_ID: 'DS',      // LINEユーザーID列(顧客管理シート無い)
      // 統一シートの、姓名とメアドの列
      SEI: 'F',
      MEI: 'G',
      MAIL: 'D'
    }
  },

  /**
  * スプレッドシートから応答設定を取得
  * キーワードに対して最大2通の応答内容を取得
  * プレースホルダーが含まれる場合は置換する
  * @param {string} userId - LINEのユーザーID
  * @returns {Promise<Object>} - キーワードとメッセージのペア
  */
  getMessagePairs_: async function(userId) {
    GeneralUtils.logDebug_('getMessagePairs_ 開始 - 引数確認', { userId: userId });
    const ss = SpreadsheetApp.openById(this.CONFIG.SPREADSHEET_ID);
    const sheet = ss.getSheetByName(this.CONFIG.SHEET_NAMES.AUTO_RESPONSE);
    const data = sheet.getDataRange().getValues();

    // スプレッドシートの変更を強制的に反映
    SpreadsheetApp.flush();

    // 統一シートから、ターゲットにすべき顧客のsei,meiを取得
    const placeholders = await this.getUserInfo_(userId);
    
    const messagePairs = {};

    // 1行目はヘッダーなのでスキップ
    for (let i = 1; i < data.length; i++) {
      
      const cols = {
        keyword: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.KEYWORD) - 1,
        response1: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.RESPONSE1) - 1,
        response2: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.RESPONSE2) - 1,
        delay: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.DELAY) - 1,
        flag: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.FLAG_COLUMN) - 1,
        altText1: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.ALT_TEXT_1) - 1,
        altText2: GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.ALT_TEXT_2) - 1
      };

      const keyword = data[i][cols.keyword];
      const response1 = data[i][cols.response1];
      const response2 = data[i][cols.response2];
      const delayMinutes = data[i][cols.delay];
      const flagColumn = data[i][cols.flag];

      if (keyword && response1) {
        messagePairs[keyword] = {
          messages: [
            {
              content: response1.startsWith('https://docs.google.com/')
                ? await this.getDocumentContent_(response1, placeholders)
                : this.replacePlaceholders_(response1, placeholders),
              isDocument: response1.startsWith('https://docs.google.com/'),
              altText: this.replacePlaceholders_(data[i][cols.altText1] || '画像メッセージ', placeholders)
            }
          ],
          delayMinutes: delayMinutes || 0,
          flagColumn: flagColumn
        };


        // 2通目の応答内容が存在する場合は追加
        if (response2) {
          messagePairs[keyword].messages.push({
            content: response2.startsWith('https://docs.google.com/')
              ? await this.getDocumentContent_(response2, placeholders)
              : this.replacePlaceholders_(response2, placeholders),
            isDocument: response2.startsWith('https://docs.google.com/'),
            altText: this.replacePlaceholders_(data[i][cols.altText2] || '画像メッセージ', placeholders)
          });
          GeneralUtils.logDebug_('getMessagePairs_ - 2通目の応答内容を追加', {
            keyword: keyword,
            messages: messagePairs[keyword].messages
          });
        }
      } else {
        GeneralUtils.logDebug_('getMessagePairs_ - キーワードまたは応答内容1が空のためスキップ', { 行番号: i });
      }
    }

    GeneralUtils.logDebug_('getMessagePairs_ 完了', {
      取得キーワード数: Object.keys(messagePairs).length,
      キーワード一覧: Object.keys(messagePairs)
    });

    return messagePairs;
  },

  /**
  * 統一シートからユーザー情報を取得する
  * @param {string} userId - LINEのユーザーID
  * @return {Promise<Object>} - プレースホルダーに対応する情報を含むオブジェクト
  */
  getUserInfo_: async function(userId) {
    const ss = SpreadsheetApp.openById(this.CONFIG.SPREADSHEET_ID);
    const sheet = ss.getSheetByName(this.CONFIG.SHEET_NAMES.UNIFIED);
    const userIdColumn = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.USER_ID);
    const seiColumn = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.SEI);
    const meiColumn = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.MEI);
    const mailColumn = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.MAIL);

    // ユーザーIDから該当する行を検索
    const userIds = sheet.getRange(1, userIdColumn, sheet.getLastRow()).getValues().flat();
    const rowIndex = userIds.indexOf(userId);

    if (rowIndex === -1) {
      // ユーザーが見つからない場合は空のオブジェクトを返す
      return { sei: '', mei: '', mail: '' };
    }

    // ユーザー情報(姓、名、メールアドレス)を取得
    const sei = sheet.getRange(rowIndex + 1, seiColumn).getValue();
    const mei = sheet.getRange(rowIndex + 1, meiColumn).getValue();
    const mail = sheet.getRange(rowIndex + 1, mailColumn).getValue();

    GeneralUtils.logDebug_('ユーザー情報取得', { userId, sei, mei, mail });
    return { sei, mei, mail };
  },

  /**
  * Googleドキュメントから本文を取得し、プレースホルダーを置換する
  * @param {string} documentUrl - ドキュメントのURL
  * @param {Object} placeholders - プレースホルダーと値のペア
  * @return {Promise<string>} - プレースホルダーが置換されたドキュメントの本文
  */
  getDocumentContent_: async function(documentUrl, placeholders) {
    try {
      const doc = DocumentApp.openByUrl(documentUrl.trim());
      const docContent = doc.getBody().getText();
      
      return this.replacePlaceholders_(docContent, placeholders);

    } catch (error) {
      GeneralUtils.logError_('ドキュメント処理エラー', error);
      throw error;
    }
  },

  /**
  * 文字列内のプレースホルダーを置換する
  * @param {string} text - プレースホルダーを含む文字列
  * @param {Object} placeholders - プレースホルダーと値のペア
  * @return {string} - プレースホルダーが置換された文字列
  */
  replacePlaceholders_: function(text, placeholders) {
    if (!text) return '';

    let replacedText = text;
    for (const key in placeholders) {
      const regex = new RegExp(`\\{${key}\\}`, 'g');
      replacedText = replacedText.replace(regex, placeholders[key]);
    }
    return replacedText;
  },


  /**
   * =====================================================
   * メッセージ処理のメインロジック
   * キーワードマッチ後の応答処理全体を制御
   * 
   * @param {Object} userInfo ユーザー情報(必須項目:userId, matchedKeyword)
   * - PhoneNumberProcessor.gsからの呼び出し時:KEY_FOR_FRESHがmatchedKeywordとして渡される
   * - 通常の自動応答時:ユーザー入力に対するキーワードマッチング結果が渡される
   * @returns {Promise<Object>} 処理結果
   * =====================================================
   */
  process_: async function(userInfo) {
    GeneralUtils.logDebug_('AutoResponseProcessor開始', userInfo);

    try {
      if (!userInfo.userId || !userInfo.matchedKeyword) {
        return {
          handled: false,
          debugMessage: 'キーワードマッチ情報がありません'
        };
      }

      // すでに応答済みかチェック
      try {
        const messagePairs = await this.getMessagePairs_(userInfo.userId);
        const responseData = messagePairs[userInfo.matchedKeyword];
        
        if (!responseData || !responseData.flagColumn) {
          GeneralUtils.logDebug_('フラグ列指定なし、処理継続', userInfo.matchedKeyword);
        } else {
          // 統一シートで該当ユーザーのフラグ確認
          const ss = SpreadsheetApp.openById(this.CONFIG.SPREADSHEET_ID);
          const sheet = ss.getSheetByName(this.CONFIG.SHEET_NAMES.UNIFIED);
          const userIdCol = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.USER_ID);
          const flagCol = GeneralUtils.columnToNumber_(responseData.flagColumn);
          
          // ユーザーIDで該当行を検索
          const userIds = sheet.getRange(1, userIdCol, sheet.getLastRow()).getValues();
          const rowIndex = userIds.findIndex(row => row[0] === userInfo.userId);

          if (rowIndex !== -1) {
            const flagValue = sheet.getRange(rowIndex + 1, flagCol).getValue();
            if (flagValue === 1) {
              GeneralUtils.logDebug_('既に応答済みのユーザー', {
                userId: userInfo.userId,
                keyword: userInfo.matchedKeyword
              });
              return {
                handled: true,
                debugMessage: '既に応答済みのため、処理をスキップしました',
                alreadyResponded: true
              };
            }
          }
        }
      } catch (error) {
        GeneralUtils.logError_('応答済みチェック時にエラー', error);
        // エラー時は安全のため処理を継続
      }

      // PhoneNumberProcessorから呼ばれた場合も、通常の自動応答と同様に
      // matchedKeyword(この場合はKEY_FOR_FRESH)を使って
      // 自動応答シートから対応するメッセージを取得
      const messagePairs = await this.getMessagePairs_(userInfo.userId);
      const responseData = messagePairs[userInfo.matchedKeyword];

      if (!responseData) {
        GeneralUtils.logDebug_('キーワードに対応するメッセージが未設定', userInfo.matchedKeyword);
        return {
          handled: false,
          debugMessage: 'メッセージ設定が見つかりません'
        };
      }

      const messages = responseData.messages;
      const delayMinutes = responseData.delayMinutes || 0;
      const flagColumn = responseData.flagColumn;

      if (delayMinutes === 0) {
        try {
          // 即時応答の場合
          const accessToken = LineUtils.getAccessToken_();

          for (const msg of messages) {
            if (msg.content) {
              await LineUtils.sendMessage_(
                userInfo.userId, 
                { 
                  content: msg.content, 
                  altText: msg.altText 
                }, 
                accessToken
              );
            } else {
              GeneralUtils.logDebug_('メッセージ内容が undefined です', msg);
            }
          }

          await this.updateResponseFlag_(userInfo.userId, flagColumn, true);
          await this.logAutoResponse_(userInfo, responseData, true);

          return {
            handled: true,
            debugMessage: `キーワード「${userInfo.matchedKeyword}」に対して即時応答しました。`,
            keyword: userInfo.matchedKeyword,
            immediate: true
          };

        } catch (error) {
          GeneralUtils.logError_('即時メッセージ送信エラー', error);
          throw error;
        }

      } else { 
        // 遅延応答の場合
        const scheduledTime = new Date();
        scheduledTime.setMinutes(scheduledTime.getMinutes() + delayMinutes);

        for (let i = 0; i < messages.length; i++) {
          if (messages[i].content) {
            await this.queueMessage_(
              userInfo.userId,
              { 
                content: messages[i].content, 
                altText: messages[i].altText 
              },
              scheduledTime,
              i === messages.length - 1 ? flagColumn : null,  // 最後のメッセージにのみフラグ列を設定
              i * 10  // メッセージごとに10ミリ秒ずらす
            );
          } else {
            GeneralUtils.logDebug_('メッセージ内容が undefined です', messages[i]);
          }
        }

        await this.logAutoResponse_(userInfo, responseData, false);

        return {
          handled: true,
          debugMessage: `キーワード「${userInfo.matchedKeyword}」に対する応答をキューに追加しました。`,
          keyword: userInfo.matchedKeyword,
          scheduledDelay: delayMinutes
        };
      }

    } catch (error) {
      GeneralUtils.logError_('AutoResponseProcessorエラー', error);
      return {
        handled: false,
        debugMessage: 'エラーが発生しました: ' + error.message
      };
    }
  },


  /**
   * メッセージをキューに追加
   * 遅延メッセージを一時保管し、後続の処理で送信
   * @param {string} userId ユーザーID
   * @param {Object} messageObj メッセージオブジェクト (content, altText を含む)
   * @param {Date} scheduledTime 送信予定時刻 (Date オブジェクト)
   * @param {string} flagColumn フラグを立てる列
   * @param {number} offsetTime createdAt をずらす時間 (ミリ秒)
   * @private
   */
  queueMessage_: async function(userId, messageObj, scheduledTime, flagColumn, offsetTime = 0) {
    // キューに保存するメッセージデータを作成
    const messageData = {
      userId: userId,
      message: messageObj, // メッセージオブジェクトを保存 (content, altText を含む)
      scheduledTime: scheduledTime.getTime(),
      createdAt: new Date().getTime() + offsetTime, // offsetTime を加算
      flagColumn: flagColumn
    };

    // PropertiesServiceを使用してキューを取得・更新
    const properties = PropertiesService.getScriptProperties();
    const currentQueue = JSON.parse(properties.getProperty('MESSAGE_QUEUE') || '[]');
    currentQueue.push(messageData);
    properties.setProperty('MESSAGE_QUEUE', JSON.stringify(currentQueue));

    GeneralUtils.logDebug_('メッセージをキューに追加', messageData);
  },

  /**
   * 送信予定メッセージの処理
   * キューに保存された遅延メッセージを定期的にチェックして送信
   * トリガーで定期的に実行される
   */
  processQueue_: async function() {
    GeneralUtils.logDebug_('キュー処理開始');

    const properties = PropertiesService.getScriptProperties();
    const currentQueue = JSON.parse(properties.getProperty('MESSAGE_QUEUE') || '[]');
    const now = new Date().getTime();
    const remainingMessages = [];

    for (const msg of currentQueue) {
      if (msg.scheduledTime <= now) {
        try {
          const accessToken = LineUtils.getAccessToken_();
          await LineUtils.sendMessage_(msg.userId, msg.message, accessToken);
          
          if (msg.flagColumn) {
            await this.updateResponseFlag_(msg.userId, msg.flagColumn, false);
          }

          GeneralUtils.logDebug_('メッセージ送信成功', msg.userId);
        } catch (error) {
          GeneralUtils.logError_('メッセージ送信エラー', error);
          remainingMessages.push(msg);
        }
      } else {
        remainingMessages.push(msg);
      }
    }

    properties.setProperty('MESSAGE_QUEUE', JSON.stringify(remainingMessages));
    GeneralUtils.logDebug_('キュー処理完了', `残りのメッセージ数: ${remainingMessages.length}`);
  },

  /**
   * 応答後のフラグ更新処理
   * 統一シート内の指定された列に応答済みフラグを設定
   * @param {string} userId LINE ユーザーID
   * @param {string} flagColumn フラグを立てる列(アルファベット)
   * @param {boolean} immediate 即時応答かどうか
   * @private
   */
  updateResponseFlag_: async function(userId, flagColumn, immediate) {
    try {
      // フラグ列が指定されていない場合は処理をスキップ
      if (!flagColumn) {
        GeneralUtils.logDebug_('フラグ列が指定されていません');
        return;
      }

      const ss = SpreadsheetApp.openById(this.CONFIG.SPREADSHEET_ID);
      const sheet = ss.getSheetByName(this.CONFIG.SHEET_NAMES.UNIFIED);

      // 列番号の計算(アルファベット → 数値)
      const userIdCol = GeneralUtils.columnToNumber_(this.CONFIG.COLUMN_NAMES.USER_ID);
      const flagCol = GeneralUtils.columnToNumber_(flagColumn);

      // ユーザーIDで該当行を検索
      const userIds = sheet.getRange(1, userIdCol, sheet.getLastRow()).getValues();
      const rowIndex = userIds.findIndex(row => row[0] === userId);

      if (rowIndex !== -1) {
        // 該当行が見つかった場合、フラグを設定(1を入力)
        const actualRow = rowIndex + 1;  // 0始まりのインデックスを1始まりの行番号に変換
        sheet.getRange(actualRow, flagCol).setValue(1);

        GeneralUtils.logDebug_('応答フラグを更新しました', {
          userId: userId,
          row: actualRow,
          flagColumn: flagColumn,
          immediate: immediate
        });
      } else {
        GeneralUtils.logDebug_('ユーザーIDに該当する行が見つかりません', userId);
      }
    } catch (error) {
      GeneralUtils.logError_('updateResponseFlag_', error);
    }
  },

  /**
  * 自動応答のログを記録する
  * @param {Object} userInfo - ユーザー情報
  * @param {Object} responseData - 応答データ
  * @param {boolean} isImmediate - 即時応答かどうか
  * @private
  */
  logAutoResponse_: async function(userInfo, responseData, isImmediate) {
    try {
      const ss = SpreadsheetApp.openById(this.CONFIG.SPREADSHEET_ID);
      const logSheet = ss.getSheetByName('自動送信ログ');

      // 現在の日時を取得
      const now = new Date();
      const timestamp = Utilities.formatDate(now, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss');

      // ユーザーのLINE名を取得
      const profile = await LineUtils.getUserName_(userInfo.userId);
      const userName = profile.success ? profile.displayName : "不明";

      // 送信内容1のログ
      let message1Log = responseData.messages[0].content;
      if (responseData.messages[0].content.startsWith('https://docs.google.com/')) {
        // Google ドキュメントの URL の場合、最初の50文字を表示
        message1Log = responseData.messages[0].content.replace(/\n/g, ' ').slice(0, 50);
      } else if (responseData.messages[0].content.match(/\.(jpeg|jpg|gif|png)$/i)) {
        // 画像の場合は URL をそのまま表示
        // message1Log はそのまま
      } else {
        // テキストメッセージの場合、最初の50文字を表示
        message1Log = responseData.messages[0].content.replace(/\n/g, ' ').slice(0, 50);
      }

      // 送信内容2のログ (送信内容2が存在する場合のみ)
      let message2Log = '';
      if (responseData.messages.length > 1) {
        message2Log = responseData.messages[1].content;
        if (responseData.messages[1].content.startsWith('https://docs.google.com/')) {
          // Google ドキュメントの URL の場合、最初の50文字を表示
          message2Log = responseData.messages[1].content.replace(/\n/g, ' ').slice(0, 50);
        } else if (responseData.messages[1].content.match(/\.(jpeg|jpg|gif|png)$/i)) {
          // 画像の場合は URL をそのまま表示
          // message2Log はそのまま
        } else {
          // テキストメッセージの場合、最初の50文字を表示
          message2Log = responseData.messages[1].content.replace(/\n/g, ' ').slice(0, 50);
        }
      }

      // ログデータの作成
      const logData = [
        timestamp,                    // A列: タイムスタンプ
        userInfo.userId,             // B列: ユーザーID(全文字)
        userName,                    // C列: ユーザーのLINE名
        userInfo.matchedKeyword,     // D列: マッチしたキーワード
        message1Log,                 // E列: 送信内容1
        message2Log,                 // F列: 送信内容2
        isImmediate ? 0 : responseData.delayMinutes  // G列: 遅延時間(分)
      ];

      // ログの追記
      logSheet.appendRow(logData);

      GeneralUtils.logDebug_('自動応答ログを記録しました', logData);
    } catch (error) {
      GeneralUtils.logError_('logAutoResponse_', error);
    }
  },

  /**
  * 古いメッセージキューの削除をやる関数
  */
  dailyMaintenanceForAR: function () {
    GeneralUtils.logDebug_('dailyMaintenance 開始');

    try {
      const properties = PropertiesService.getScriptProperties();
      const now = new Date().getTime();
      const TWO_DAYS_MS = 48 * 60 * 60 * 1000;  // 2日間のミリ秒

      // メッセージキューの整理(2日以上経過したものを削除)
      const currentQueue = JSON.parse(properties.getProperty('MESSAGE_QUEUE') || '[]');
      const updatedQueue = currentQueue.filter(msg => {
        return (now - msg.createdAt) < TWO_DAYS_MS;
      });
      properties.setProperty('MESSAGE_QUEUE', JSON.stringify(updatedQueue));

      GeneralUtils.logDebug_('dailyMaintenanceForAR 正常終了');

    } catch (error) {
      GeneralUtils.logError_('dailyMaintenanceForAR エラー', error);
    }
  }

}; // const AutoResponseProcessorの終わりカッコ


/**
* トリガーから見えるようにするため(ラッパー関数)
*/
function processQueueOfAR() {
  AutoResponseProcessor.processQueue_();
}

function dailyMaintenanceForAR() {
  AutoResponseProcessor.dailyMaintenanceForAR();
}


/**
* トリガー作るためのおまけ
* 時間間隔設定とか自動でやってくれる
*/
function setupTriggersForAutoResponse() {
  ScriptApp.newTrigger('processQueueOfAR')
    .timeBased()
    .everyHours(1)
    .create();

  ScriptApp.newTrigger('dailyMaintenanceForAR')
    .timeBased()
    .everyDays(1)
    .atHour(3) // 毎日午前3時に実行
    .create();
};


今後修正すべき点

Claudeのクセとして、やたら細かく関数を分離させたがる。
そのせいで、あっちこっちに処理の順番が飛んで、逆に分かりづらくなってる箇所がある。

TextEventProcessors.processMessage_()あたり

GAS
const matchedKeyword = await this.shouldProcessAutoResponse_(userInfo); 

↑ shouldProcessAutoResponse_の処理の短さを考えると、別に分ける必要はない。

GAS
const autoResponseResult = await AutoResponseProcessor.process_(userInfo);
return { 
    handled: true,
    processor: 'キーワード応答',
    result: autoResponseResult
};

↑ return以降の戻り値が他の箇所で使われずに捨てられているので、AutoResponseProcessorの処理をawaitする必要がない。

Discussion