4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-03-11

背景

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

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

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

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

全体構造

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する必要がない。

4
5
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
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?