🉐

【コード全文公開】LINEの2秒応答ルールに対応するために四苦八苦した結果の doPost

に公開

経緯は下記記事見てください

https://zenn.dev/sh102/articles/49f8fa556910d1


/**
 * スクリプトID:1XXXXXXXXXXXXXX
 *
 * LINE 公式アカウントで、グループIDやユーザーIDを取得してスプレッドシートに溜めていくモジュール
 * - 公式アカウントがグループか複数人ルームに招待されたら groupか room ID を自動取得、登録
 * - メンバーが発言したらそのユーザーIDをスプレッドシートに登録。(重複確認して、登録済みなら終了)
 * - メンバーが退出したらそのユーザーIDをスプレッドシートから削除。
 * - LockServiceによる同時実行制御、CacheServiceによる重複イベント防止とパフォーマンス最適化を行う。
 */

/*
🔶 全体構造図
=====================================================
📦 doPost(e) ← LINEからのWebhook受信エントリーポイント
   ├── isValidationEvent_() ← 検証イベント判定(高速化)
   ├── PropertiesService ← イベントをキューに保存(UUID付与)
   └── ensureWorkerScheduled_() ← デバウンス付きワーカー起動

📦 processQueuedEvents_() ← トリガーから呼び出される非同期ワーカー
   ├── DoPostLogger.flushQueuedLogs_() ← ログ書き出し
   ├── キュー処理ループ
   │   └── handleWebhook_() ← 実処理本体
   └── cleanupCurrentTriggers_() ← トリガークリーンアップ

📦 handleWebhook_(payloadStr) ← 実際のイベント処理
   ├── LockService ← 同時実行制御(30秒ロック)
   ├── CacheService ← 重複イベント防止(60秒キャッシュ)
   └── WebhookHandler.Handlers[ev.type] ← イベント種別による処理振り分け
       ├── join ← グループ参加処理
       ├── leave ← グループ退出処理
       ├── message ← メッセージ処理
       └── memberJoined/memberLeft ← メンバー増減処理

🔧 ユーティリティ関数群
├── logMaintenance() ← ログメンテナンス
├── manualGroupNameGetter() ← グループ名手動取得
├── manualCleanupProcessTrigs() ← トリガー手動クリーンアップ
└── forceRefreshMemberCache() ← メンバーキャッシュ強制更新
=====================================================
*/


/**
 * =================================================================
 * 🔵 スプレッドシート関連の共通設定
 * ・ID、シート名、ヘッダー行数、列定義などを一元管理する
 * =================================================================
 */
const SsCfgDoPost = {
  ID: '1YXXXXXXXXXXXXXX', // doPostのログと各IDを保存するスプレッドシート

  SHEETS: {
    DEBUG : 'DebugLog', // デバッグ用
    USER  : 'User',     // ユーザーID保管用
    GROUP : 'Group'     // ルームかグループのID保管用
  },

  HEADER_ROWS: { // ヘッダー行を念の為、冒頭で設定できるようにしておく
    DEBUG : 1,
    USER  : 1,
    GROUP : 1
  },

  // --- 列定義 ---
  COLS: {
    // Groupシート
    GROUP: {
      ID   : 'A', // ルームかグループのID
      NAME : 'B'  // ルームかグループの名前
    },
    // Userシート
    USER: {
      USER_ID  : 'A', // ユーザーID
      NAME     : 'B', // ユーザー名
      GROUP_ID : 'C', // 参加しているルーム/グループのID
      GR_NAME  : 'D'  // 参加しているルーム/グループの名前
    },
    // ログシート
    DEBUG: {
      TIME     : 'A', // 発生時刻
      GROUP    : 'B', // 発生グループ名
      MESSAGE  : 'C'  // ログメッセージ
    }
  },

  // --- ユーティリティ関数のエイリアス ---
  // ※ GeneralUtilsがGASエディタでこのファイルより下にある場合も考慮し、遅延参照形式にしておく
  // 以下の形式は、メソッド定義のショートハンド記法。 `colToNumb: function(col) { ... }` と書くのと同じ意味。
  colToNumb(col) { return GeneralUtils.colToNumb_(col); }
};


/**
 * =================================================================
 * 🔵 LINE Messaging API 関連設定
 * =================================================================
 */
const ChannelAccessToken = 'wawXXXXXXXXXXXXXXFU=';


/**
 * =================================================================
 * 🔴 メイン処理 ここから
 * =================================================================
 */


/**
 * 🟡 LINE Messaging APIからのWebhookリクエストを処理するエンドポイント
 * ここでは「まず即時に200を返す」ことを最優先にし、重い処理は後段のワーカーへ委譲します。
 * - LINEの再送(Redelivery)を防ぐため、2秒以内に応答を返す必要があります。
 * - 受信ペイロードは ScriptProperties にキューとして一時保存します(最も手軽で依存の少ない簡易キュー)。
 * - すぐに時限トリガーを作成し、保存済みペイロードを後段でまとめて処理します。
 * - GASの制約上、doPostでreturnすると普通は完全終了しちゃうから、トリガーで続きを復活させる必要がある(非同期処理)
 *
 * @param {GoogleAppsScript.Events.DoPost} e - Webhookイベントオブジェクト
 * @returns {GoogleAppsScript.Content.TextOutput} LINEサーバーへのレスポンス
 */

function doPost(e) {

  try {
    // 受信した生ペイロード(JSON文字列)をそのまま保存。
    // ※ ここでパースは行わず、後段で実施。パースやロック取得などの重い処理を避け、応答速度を最優先にする。
    const payloadStr = (e && e.postData && e.postData.contents) ? e.postData.contents : '{}';

    // 検証イベントかどうかをチェック
    const isValidation = isValidationEvent_(payloadStr);

    DoPostLogger.logForDebug_(`doPost開始→検証判定: ${isValidation ? '検証イベント(スキップ)' : '通常イベント'}`);

    if (isValidation) {
      DoPostLogger.queueLogs_(); // 検証イベントであることをログに書くため、キューに保存(後段で処理)
      ensureWorkerScheduled_(); // 検証イベントでもワーカーを起動
      return HtmlService.createHtmlOutput('OK');
    }

    const uuid = Utilities.getUuid();

    // イベントごとにUUIDを付与してスクリプトプロパティに保存
    // 例:未処理イベント:aaaa-bbbb-cccc: (イベント1のJSONデータ)、未処理イベント:eeee-ffff-gggg: (イベント2のJSONデータ)
    PropertiesService.getScriptProperties().setProperty('未処理イベント:' + uuid, payloadStr);


    // デバウンス状態を確認してログ出力
    const cache = CacheService.getScriptCache();
    const workerFlag = cache.get('ワーカー処理中');

    DoPostLogger.logForDebug_(`キュー保存完了→トリガー重複防止チェック: UUID=${uuid.substring(0, 8)}...,
     他の処理=${workerFlag ? '実行中(スキップ)' : 'なし(作成OK)'}`);


    // ➡️ トリガー乱立防止のデバウンス付きスケジューラー
    ensureWorkerScheduled_();

    DoPostLogger.logForDebug_(`トリガー作成→doPost完了`);

  } catch (err) {

    // 応答だけは返す。キュー投入に失敗した場合はログへ。
    DoPostLogger.logForDebug_('doPost: キュー投入時にエラーが発生しました', { message: err.message, stack: err.stack });
    DoPostLogger.flushLogs_(); // エラー時は即座にログを書き出し
  }

  // ここで即時に200を返す。HTML本文は軽量化のため短い文字列でOK。
  DoPostLogger.queueLogs_(); // doPost内のログをキューに保存(後段で処理)
  return HtmlService.createHtmlOutput('OK');
}

/**
 * 🔍 検証イベントかどうかを軽量判定する
 * - LINE Developer Consoleの「検証」ボタンで送られてくるダミーデータを識別
 * - 高速化のため、JSON.parseは最小限に抑制
 * @param {string} payloadStr - 受信したJSON文字列
 * @returns {boolean} 検証イベントの場合true
 * @private
 */
function isValidationEvent_(payloadStr) {
  try {
    // 空データや不正データは検証イベントとして扱う
    if (!payloadStr || payloadStr === '{}') {
      return true;
    }

    // 軽量チェック:eventsが空配列または存在しない場合は検証イベント
    // ※ JSON.parseを避けて文字列検索で高速化
    if (payloadStr.includes('"events":[]') || !payloadStr.includes('"events"')) {
      return true;
    }

    // より詳細なチェックが必要な場合のみJSON.parseを実行
    const data = JSON.parse(payloadStr);

    // eventsが空、または実際のイベントデータがない場合は検証イベント
    if (!data.events || data.events.length === 0) {
      return true;
    }

    // 実際のユーザーイベントには必ずtypeが存在する
    const firstEvent = data.events[0];
    if (!firstEvent.type) {
      return true;
    }

    return false; // 通常のイベント

  } catch (err) {
    // パースエラーの場合は検証イベントとして扱い、処理をスキップ
    return true;
  }
}

/**
 *  🟡 ワーカー起動のデバウンス関数
 * - doPostが高頻度で発火しても、短時間にトリガーを乱立させない。
 * - 仕組み:
 *   1) CacheService にフラグ(ワーカー処理中)を立てる(Time To Live 90秒)
 *   2) 既にフラグが立っていれば新規トリガーは作らない
 *   3) 念のため既存トリガー一覧も確認し、同一ハンドラのものがあれば作成をスキップ
 */
function ensureWorkerScheduled_() {

  const cache = CacheService.getScriptCache();

  // 既にキャッシュ内にフラグが立っていればスキップ
  const flag = cache.get('ワーカー処理中'); // ※ .get()は、CacheServiceから指定したキーの値を取得するメソッド。
  if (flag) return;

  // 先にフラグを立てて競合回避(TTLは短め)
  // processQueuedEvents_が失敗しても、90秒後にはフラグは自動的に解除される。
  cache.put('ワーカー処理中', '1', 90); // ※ ⚠️ キャッシュ有効期限はミリ秒じゃなくて秒で指定できる

  // 数秒後に1回だけ実行されるトリガーを作成
  ScriptApp.newTrigger('processQueuedEvents_')
           .timeBased()
           .after(5)
            // 処理の衝突を防ぐためのごく僅かな遅延。
            //※.after()はミリ秒単位で指定するが、GAS仕様上、実際には現在時刻 +1分 のトリガーが設定されちゃう?
           .create();
}

/**
 * 🟡 キューワーカー:トリガーから呼び出される。
 * ScriptProperties に溜まっているペイロード(JSON文字列)を順に取り出して処理し、処理後に削除する。
 */
function processQueuedEvents_() {

  // --- 1. まず未処理ログを処理してスプレッドシートに書き込む ---
  DoPostLogger.flushQueuedLogs_();

  // doPostで保存した各イベントを見に行く
  const props = PropertiesService.getScriptProperties();

  const allKeys = props.getKeys() || [];
  // ScriptPropertiesの中にある、「未処理イベント:」で始まるキーだけを全部探す。
  // ※ .filter()は、配列に指定した条件を満たす要素を抽出する。
  const keys = allKeys.filter(k => k.indexOf('未処理イベント:') === 0);

  // 🎓 学習用ログ: processQueuedEvents_開始
  DoPostLogger.logForDebug_(`processQueuedEvents_開始: キュー数=${keys.length}`);

  // 実行時間制限対策のロジックは、ユースケースを考慮して削除。
  // 一度の実行でキューを全て処理しきることを想定する。
  for (const key of keys) {
    const payloadStr = props.getProperty(key);
    if (!payloadStr) {
      // 破損したキーは削除してスキップ
      props.deleteProperty(key);
      continue;
    }

    try {
      // 🎓 学習用ログ: イベント処理開始
      const shortKey = key.replace('未処理イベント:', '').substring(0, 8) + '...';
      DoPostLogger.logForDebug_(`イベント処理: ${shortKey} → handleWebhook_呼び出し`);

      // ➡️ 実処理に渡す
      handleWebhook_(payloadStr);

    } catch (err) {
      DoPostLogger.logForDebug_('processQueuedEvents_: 処理中にエラーが発生しました', { message: err.message, stack: err.stack });

    } finally {
      // 後始末:必ずキューを空にする
      props.deleteProperty(key);
    }
  }

  // まだ未処理が残っている場合は自己再スケジュール(フラグは維持)
  // ※ このワーカーの処理中にdoPostで新たなイベントが追加されたケースに対応するためのチェック
  const remaining = (props.getKeys() || []).some(k => k.indexOf('未処理イベント:') === 0);

  // 🎓 学習用ログ: 処理完了状態
  DoPostLogger.logForDebug_(`処理完了: ${remaining ? '継続スケジュール' : 'トリガークリーンアップ'}`);

  if (remaining) {
    // 現在のトリガーを削除してから新しいトリガーを作成(トリガー蓄積を防ぐ)
    cleanupCurrentTriggers_();

    ScriptApp.newTrigger('processQueuedEvents_')
             .timeBased()
             .after(10) // 少し間を置いて続きのバッチを処理
             .create();

  } else {

    // 処理完了:トリガーを削除してフラグを解除
    cleanupCurrentTriggers_();
    CacheService.getScriptCache().remove('ワーカー処理中');
  }
}

/**
 * 🟡 processQueuedEvents_のトリガーをクリーンアップする内部関数
 * - トリガーの蓄積を防ぐため、実行完了時や再スケジュール時に既存トリガーを削除
 * @private
 */
function cleanupCurrentTriggers_() {
  try {
    const triggers = ScriptApp.getProjectTriggers();
    let deletedCount = 0;

    // processQueuedEvents_のトリガーを全て削除
    triggers.forEach(trigger => {
      if (trigger.getHandlerFunction() === 'processQueuedEvents_') {
        ScriptApp.deleteTrigger(trigger);
        deletedCount++;
      }
    });

    if (deletedCount > 0) {
      DoPostLogger.logForDebug_(`cleanupCurrentTriggers_: ${deletedCount}個のprocessQueuedEvents_トリガーを削除しました`);
    }
  } catch (err) {
    DoPostLogger.logForDebug_('cleanupCurrentTriggers_でエラーが発生しました', { message: err.message, stack: err.stack });
  }
}

/**
 * 🔸 実処理本体
 * @param {string} payloadStr - doPostで受け取ったJSON文字列(e.postData.contents と同一形式)
 *
 * - HTTPレスポンスは返さない(ここはHTTPコンテキスト外)
 */
function handleWebhook_(payloadStr) {

  // e.postData.contentsはLINEからのリクエスト本体(JSON形式の文字列)
  // ※ || ' {}' は、左側がfalsy値(undefined, null, ""など)の場合に右側の '{}' を採用するというJavaScriptの短絡評価。これにより、データが空でもエラーになりにくい。

  const data = JSON.parse(payloadStr || '{}');

  if (!data.events || !data.events.length) {
    // LINEからのリクエストにイベントが含まれていない場合は、処理不要のため終了。
    // (HTTP応答は doPost 側で完了済み)
    return;
  }

  // 30秒ロックを取得し、同時実行を防ぐ
  // ※ LockServiceは、複数のユーザーやプロセスが同時に同じ処理を実行しないように制御するためのGASの機能。
  // これにより、例えば同じイベントがほぼ同時に2回届いても、処理が二重に実行されるのを防ぐことができる。
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) {
    DoPostLogger.logForDebug_('handleWebhook_: ロックの取得にタイムアウトしました');
    DoPostLogger.flushLogs_(); // エラー時は即座にログを書き出し
    return;
  }

  try {
    const ev = data.events[0]; // イベントデータの最初の要素を取得

    // --- 1. まず重複イベントをキャッシュで弾く ---
    if (ev.webhookEventId) {

      // 一度処理したイベントIDをキャッシュサービスで短時間保存し、同じIDのイベントが再度来た場合に処理をスキップする。
      // これにより、LINEサーバーからの再送リクエストによる重複処理を防ぐ。
      const cache = CacheService.getScriptCache();
      if (cache.get(ev.webhookEventId)) {

        // 重複の場合はここでサクッとプログラムが終わるので、ログをバッファに溜めずこの場で書いちゃう
        const sourceId = WebhookHandler.getSourceId_(ev.source);
        DoPostLogger.logForDebug_(`重複イベントを無視: ${ev.webhookEventId}`, null, sourceId);
        DoPostLogger.flushLogs_();
        return;
      }

      // 60秒間、イベントIDをキャッシュに保存
      cache.put(ev.webhookEventId, 'processed', 60);
    }

    // --- 2. 重複してなければイベントに応じたハンドラを呼び出す ---

    // 学習用ログ: 重複チェック完了とハンドラ実行
    const eventId = ev.webhookEventId ? ev.webhookEventId.substring(0, 8) + '...' : 'なし';
    DoPostLogger.logForDebug_(`重複チェック→ハンドラ実行: ${ev.type}イベント, ID=${eventId}`);

    // ev.type('join', 'message'など)の文字列をキーとして、Handlersオブジェクトから対応する関数を取得。
    // このように書くと、if文やswitch文でイベントの種類を一つ一つチェックするよりもコードが簡潔になる。
    // JavaScriptでよく使われる、処理を動的に振り分けるためのデザインパターンの一つ。
    const handler = WebhookHandler.Handlers[ev.type];

    if (handler) {
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      DoPostLogger.logForDebug_(`イベント[${ev.type}]のハンドラを実行します。`, ev, sourceId);
      handler(ev);

    } else {
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      DoPostLogger.logForDebug_(`イベント[${ev.type}]のハンドラが見つかりません。スキップします。`, null, sourceId);
    }

  } catch (err) {
    DoPostLogger.logForDebug_('handleWebhook_で予期せぬエラーが発生しました。', { message: err.message, stack: err.stack }, null);

  } finally {

    // --- 3. 溜めたログを全て書き出し、ロックを解放 ---

    // ※ finallyブロック内の処理は、tryブロックで処理が成功しようが、catchブロックでエラーが捕捉されようが、
    // どちらの場合でも最後に必ず実行される。ロックの解放など、処理の結果によらず必須の後始末に使う。
    DoPostLogger.flushLogs_();
    lock.releaseLock();
  }

}


/**
 * =================================================================
 * 🔴 Webhookイベントを処理するハンドラ群
 * =================================================================
 */
const WebhookHandler = {

  /**
   * --- 🔶 イベント振り分けオブジェクト ---
   * LINEから送られてくるWebhookイベントの `type` (`ev.type`) の値と
   * それに対応して実行する関数をマッピング。
   */
  Handlers: {

    // ※ (ev) => WebhookHandler.handleJoin_(ev) はアロー関数式。
    // function(ev) { return WebhookHandler.handleJoin_(ev); } の省略形で、より簡潔に書ける。

    join: (ev) => WebhookHandler.handleJoin_(ev),
    memberJoined: (ev) => WebhookHandler.handleMemberJoined_(ev),
    memberLeft: (ev) => WebhookHandler.handleMemberLeft_(ev),
    message: (ev) => WebhookHandler.handleMessage_(ev)
    // 🔷 他のイベントが必要な場合はここに追加

  },

  /**
   * --- 🔶 実働関数群 ---
   */

  /**
   *  Botがグループ/ルームに参加した際の処理 (joinイベント)
   * @param {object} ev - Webhookイベントのペイロード
   */
  handleJoin_: (ev) => {
    try {
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      if (!sourceId) return;

      const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
      const sheet = ss.getSheetByName(SsCfgDoPost.SHEETS.GROUP);
      const headerRows = SsCfgDoPost.HEADER_ROWS.GROUP;
      const lastRow = sheet.getLastRow();

      // A列をチェックして既に登録済みか確認
      if (lastRow >= headerRows) { // 全く空のシートじゃないか確認

        // ※ .getValues()は二次元配列(例: [ ['id1'], ['id2'] ])で値を返す。
        // ※ .flat()メソッドは、このようなネストした配列を一段階平坦化して、一次元配列(例: ['id1', 'id2'])に変換する。
        const idColValues = sheet.getRange(headerRows + 1, 1, lastRow - headerRows + 1, 1).getValues().flat();

        // ※ .includes()は、配列に指定した要素が含まれているかどうかを true / false で返す。
        if (idColValues.includes(sourceId)) {
          DoPostLogger.logForDebug_(`グループ/ルーム [${sourceId}] は登録済み。join処理をスキップ。`, null, sourceId);
          return;
        }
      }

      // 新規登録処理
      let sourceName = '複数人トークルーム'; // デフォルト名
      if (ev.source.type === 'group') {
        try {
          const res = WebhookHandler.fetchLineApi_(`group/${sourceId}/summary`);

          // ※ || 演算子は、左側の値がfalsy(undefined, null, "" 等)な場合に右側の値を返す。
          // ここでは、APIからgroupNameが取得できなかった場合に備えて、デフォルトの文字列を設定している。
          sourceName = res.groupName || '(グループ名取得失敗)';

        } catch (err) {
          sourceName = '(グループ名取得失敗)';
          DoPostLogger.logForDebug_(`グループ名の取得に失敗: ${sourceId}`, err.message, sourceId);
        }
      }

      // appendRow用に、設定オブジェクトに基づいた正しい順序の配列を作成
      const newRowData = [];
      const cols = SsCfgDoPost.COLS.GROUP;
      newRowData[SsCfgDoPost.colToNumb(cols.ID) - 1] = sourceId;
      newRowData[SsCfgDoPost.colToNumb(cols.NAME) - 1] = sourceName;
      sheet.appendRow(newRowData);
      DoPostLogger.logForDebug_(`新規グループ/ルーム「${sourceName}」を登録しました。`, null, sourceId);

    } catch (err) {
      const sourceId = ev ? WebhookHandler.getSourceId_(ev.source) : null;
      DoPostLogger.logForDebug_('handleJoin_でエラー', { message: err.message, stack: err.stack }, sourceId);
    }
  },

  /**
   * メンバーがグループ/ルームから退出した際の処理 (memberLeftイベント)
   * @param {object} ev - Webhookイベントのペイロード
   */
  handleMemberLeft_: (ev) => {
    try {
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      if (!sourceId || !ev.left || !ev.left.members) return;

      // ※ .map()は、配列の各要素に対して指定した処理を行い、その結果からなる新しい配列を生成する。
      // ここでは、メンバーオブジェクトの配列から、各メンバーのuserIdだけを抜き出した新しい配列を作っている。
      const leftUserIds = ev.left.members.map(m => m.userId);
      if (leftUserIds.length === 0) return;

      const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
      const sheet = ss.getSheetByName(SsCfgDoPost.SHEETS.USER);
      const headerRows = SsCfgDoPost.HEADER_ROWS.USER;
      const lastRow = sheet.getLastRow();

      if (lastRow < headerRows + 1) return; // データ行がない

      // 設定オブジェクトから列インデックスを取得
      const cols = SsCfgDoPost.COLS.USER;

      const colIdx = {
        USER_ID: SsCfgDoPost.colToNumb(cols.USER_ID) - 1,
        NAME: SsCfgDoPost.colToNumb(cols.NAME) - 1,
        GROUP_ID: SsCfgDoPost.colToNumb(cols.GROUP_ID) - 1
      };

      // 冒頭定義から最大列を認識して、最後の列として設定。getLastColより正確に冒頭設定の反映が可能
      const maxCol = Math.max(colIdx.USER_ID, colIdx.NAME, colIdx.GROUP_ID) + 1;
      const userList = sheet.getRange(headerRows + 1, 1, lastRow - headerRows, maxCol).getValues();
      const rowsToDelete = [];

      // メンバーを1人ずつ削除処理に回す
      // ※ .findIndex()は、配列の中から条件に一致する最初の要素の「インデックス番号」を返す。
      leftUserIds.forEach(userId => {

        const rowIndex = userList.findIndex(row => row[colIdx.GROUP_ID] === sourceId && row[colIdx.USER_ID] === userId);
        if (rowIndex !== -1) {
          const rowNum = headerRows + 1 + rowIndex;
          rowsToDelete.push(rowNum);
          const userName = userList[rowIndex][colIdx.NAME];
          DoPostLogger.logForDebug_(`メンバー削除: ${userName} (${userId})`, null, sourceId);
        }
      });

      // 削除対象行があれば、行番号の降順で削除(インデックスのズレを防ぐため)
      if (rowsToDelete.length > 0) {

        // ※ .sort((a, b) => b - a) は、配列を数値の降順(大きい順)に並び替えるための定型句。
        // スプレッドシートの行を削除する際、昇順で削除すると前の行を消したことで後ろの行の番号がずれてしまう。
        // そのため、後ろの行から先に削除することで、インデックスのズレを防ぐ。
        rowsToDelete.sort((a, b) => b - a).forEach(rowNum => {
          sheet.deleteRow(rowNum);
        });
        DoPostLogger.logForDebug_(`${rowsToDelete.length}名のメンバー情報をシートから削除しました。`, null, sourceId);
      }

    } catch (err) {
      const sourceId = ev ? WebhookHandler.getSourceId_(ev.source) : null;
      DoPostLogger.logForDebug_('handleMemberLeft_でエラー', { message: err.message, stack: err.stack }, sourceId);
    }
  },

  /**
   * メンバーがグループ/ルームに参加した際の処理 (memberJoinedイベント)
   * @param {object} ev - Webhookイベントのペイロード
   */
  handleMemberJoined_: (ev) => {
    try {
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      if (!sourceId || !ev.joined || !ev.joined.members) return;

      const joinedUserIds = ev.joined.members.map(m => m.userId);
      if (joinedUserIds.length === 0) return;

      // 参加したメンバーを1人ずつ追加処理に回す
      joinedUserIds.forEach(userId => {
        WebhookHandler.addMember_(ev.source.type, sourceId, userId);
      });
    } catch (err) {
      const sourceId = ev ? WebhookHandler.getSourceId_(ev.source) : null;
      DoPostLogger.logForDebug_('handleMemberJoined_でエラー', { message: err.message, stack: err.stack }, sourceId);
    }
  },

  /**
   * メッセージ受信時の処理 (messageイベント)
   * @param {object} ev - Webhookイベントのペイロード
   */
  handleMessage_: (ev) => {
    try {
      // ユーザーからのメッセージでなければ処理終了
      if (ev.source.type === 'user' || !ev.source.userId) {
        return;
      }
      const sourceId = WebhookHandler.getSourceId_(ev.source);
      const userId = ev.source.userId;

      // addMember_は内部で重複チェックを行うため、そのまま呼び出す
      WebhookHandler.addMember_(ev.source.type, sourceId, userId);
    } catch (err) {
      const sourceId = ev ? WebhookHandler.getSourceId_(ev.source) : null;
      DoPostLogger.logForDebug_('handleMessage_でエラー', { message: err.message, stack: err.stack }, sourceId);
    }
  },

  /**
   * 個別メンバーを登録(未登録の場合のみ)
   * @private
   * @param {string} sourceType - 'group' または 'room'
   * @param {string} sourceId - グループIDまたはルームID
   * @param {string} userId - 登録するメンバーのLINE User ID
   */
  addMember_: (sourceType, sourceId, userId) => {
    try {
      const cache = CacheService.getScriptCache();

      // キャッシュキーをグループIDに紐づけることで、グループごとに別々にキャッシュを管理。
      // グループ1でAさんのユーザーIDが既に登録されていても、グループ2で同じAさんの初回発言を登録できるようにする。
      const cacheKey = `user_in_${sourceId}`;

      // キャッシュから取得した値は文字列なので、JSON.parse()で配列に戻す。
      // cache.get()がnull(キャッシュがない場合)を返す可能性があるので、`|| '[]'` でデフォルトの空配列を与えている。
      let cachedUserIds = JSON.parse(cache.get(cacheKey) || '[]');

      // --- 1. まずキャッシュで重複チェック ---
      if (cachedUserIds.includes(userId)) {
        DoPostLogger.logForDebug_(`キャッシュヒット: ${userId} in ${sourceId} は登録済み`);
        return; // キャッシュに存在すれば、スプレッドシートを見ずに処理終了
      }

      const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
      const userSheet = ss.getSheetByName(SsCfgDoPost.SHEETS.USER);
      const headerRows = SsCfgDoPost.HEADER_ROWS.USER;

      // --- 2. キャッシュにない場合、スプレッドシートで重複チェック ---
      const cols = SsCfgDoPost.COLS.USER;
      const colIdx = {
        USER_ID: SsCfgDoPost.colToNumb(cols.USER_ID) - 1,
        GROUP_ID: SsCfgDoPost.colToNumb(cols.GROUP_ID) - 1
      };

      if (userSheet.getLastRow() >= headerRows + 1) {
        const maxCol = Math.max(colIdx.USER_ID, colIdx.GROUP_ID) + 1;
        const userList = userSheet.getRange(headerRows + 1, 1, userSheet.getLastRow() - headerRows, maxCol).getValues();
        const isAlreadyRegistered = userList.some(row => row[colIdx.GROUP_ID] === sourceId && row[colIdx.USER_ID] === userId);

        if (isAlreadyRegistered) {
          DoPostLogger.logForDebug_(`シート確認: ${userId} in ${sourceId} は登録済み。キャッシュを更新します。`, null, sourceId);
          // スプレッドシートには存在するがキャッシュになかった場合、キャッシュを更新して終了
          if (!cachedUserIds.includes(userId)) {
            cachedUserIds.push(userId);
            cache.put(cacheKey, JSON.stringify(cachedUserIds), 300); // 5分キャッシュ
          }
          return;
        }
      }

      // --- 3. 未登録ユーザーの登録処理 ---
      // プロフィール取得
      const profile = WebhookHandler.fetchLineApi_(`${sourceType}/${sourceId}/member/${userId}`);
      const userName = profile.displayName || '(名称未取得)';

      // グループ名取得
      const groupSheet = ss.getSheetByName(SsCfgDoPost.SHEETS.GROUP);
      const groupCols = SsCfgDoPost.COLS.GROUP;
      const groupColIdx = {
        ID: SsCfgDoPost.colToNumb(groupCols.ID) - 1,
        NAME: SsCfgDoPost.colToNumb(groupCols.NAME) - 1,
      };
      const groupMaxCol = Math.max(groupColIdx.ID, groupColIdx.NAME) + 1;
      const groupList = groupSheet.getRange(2, 1, groupSheet.getLastRow() - 1, groupMaxCol).getValues();
      const groupInfo = groupList.find(row => row[groupColIdx.ID] === sourceId);
      const groupName = groupInfo ? groupInfo[groupColIdx.NAME] : '(グループ名不明)';


      // Userシートに追記(設定オブジェクトに基づいて配列を組み立てる)
      const newUserRow = [];
      newUserRow[colIdx.USER_ID] = userId;
      newUserRow[SsCfgDoPost.colToNumb(cols.NAME) - 1] = userName;
      newUserRow[colIdx.GROUP_ID] = sourceId;
      newUserRow[SsCfgDoPost.colToNumb(cols.GR_NAME) - 1] = groupName;
      userSheet.appendRow(newUserRow);
      DoPostLogger.logForDebug_(`新規メンバー登録: ${userName}(${userId}) を ${groupName}(${sourceId}) に追加しました。`, null, sourceId);

      // キャッシュを更新
      cachedUserIds.push(userId);
      cache.put(cacheKey, JSON.stringify(cachedUserIds), 300); // 5分キャッシュ

    } catch (err) {
      DoPostLogger.logForDebug_(`addMember_でエラー (userId: ${userId}, sourceId: ${sourceId})`, { message: err.message, stack: err.stack }, sourceId);
    }
  },


  /**
   * --- 補助関数 ---
   */

  /**
   * イベントソースからIDを取得する
   * @param {object} source - イベントのsourceオブジェクト
   * @returns {string|null} 'group', 'room', 'user' に応じたID
   */
  getSourceId_: (source) => {
    // switch文は、一つの変数の値に基づいて、多くの選択肢の中から処理を分岐させたい場合に使う。
    // ここでは`source.type`の値が 'group', 'room', 'user' のどれであるかに応じて、返す値を切り替えている。
    // if...else if...else文で書くよりも、コードの意図が明確で読みやすくなる。
    switch (source.type) {
      case 'group': return source.groupId;
      case 'room': return source.roomId;
      case 'user': return source.userId;
      default: return null;
    }
  },

  /**
   * LINE APIへのリクエストを送信する
   * @param {string} endpoint - APIエンドポイント (例: 'profile/xxxxxxxx')
   * @param {string} [method='get'] - HTTPメソッド
   * @param {object} [payload=null] - POSTなどで送信するペイロード
   * @returns {object} JSONパースされたレスポンス
   */
  // 関数の引数で `method = 'get'` のように `=値` をつけると、引数が渡されなかった場合のデフォルト値を設定できる。
  fetchLineApi_: (endpoint, method = 'get', payload = null) => {
    const url = `https://api.line.me/v2/bot/${endpoint}`;
    const options = {
      method: method,
      headers: {
        'Authorization': `Bearer ${ChannelAccessToken}`
      },
      // muteHttpExceptionsをtrueに設定すると、4xxや5xxのようなHTTPエラーが発生した際に、
      // スクリプトが例外をスローして停止するのを防ぎ、代わりにエラーレスポンスをオブジェクトとして受け取れるようになる。
      // これにより、レスポンスコードを自分で確認して、リトライ処理などの柔軟なエラーハンドリングが可能になる。
      muteHttpExceptions: true
    };
    if (payload) {
      options.contentType = 'application/json';
      options.payload = JSON.stringify(payload);
    }

    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();
    const responseText = response.getContentText();

    if (responseCode !== 200) {
      throw new Error(`LINE API Error: ${responseCode} - ${responseText}`);
    }
    return JSON.parse(responseText);
  }

}; // WebhookHandler


/**
 * =================================================================
 * 🔵 ログ関連の処理を行うオブジェクト
 * =================================================================
 */
const DoPostLogger = {
  logBuffer: [], // ログを一時的に溜めるバッファ

  /**
   * デバッグログをバッファに溜める
   * @param {string} message - ログメッセージ
   * @param {any} [data=null] - 追加データ(オプション)
   * @param {string|null} [sourceId=null] - イベント発生元のID
   */
  logForDebug_(message, data = null, sourceId = null) {
    const logEntry = {
      timestamp: new Date(),
      sourceId: sourceId,
      message: message,
      data: null
    };

    if (data) {
      try {
        const stringified = JSON.stringify(data);
        // 三項演算子 (`条件 ? A : B`) は、if...else文の省略形。
        // 条件がtrueならAを、falseならBを返す。
        // ここでは、文字列の長さが40000文字を超えていたら切り詰め、そうでなければそのまま、という処理を1行で書いている。
        logEntry.data = stringified.length > 40000 ? stringified.substring(0, 40000) + '...' : stringified;
      } catch (e) {
        logEntry.data = '[データstringify化失敗]';
      }
    }
    this.logBuffer.push(logEntry);
  },

  /**
   * 溜まったログをシートに一括で書き出す
   */
  flushLogs_() {
    if (this.logBuffer.length === 0) return;

    const lock = LockService.getScriptLock();
    if (!lock.tryLock(15000)) { // ログ書き込み用に短めのロックを取得
      // ログ書き込み自体でエラーが出た場合はconsole.errorを使用(無限ループ防止)
      console.error('ログ書き込みのロック取得に失敗しました。');
      return;
    }

    try {
      const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);

      // --- グループ名解決のための対応表を作成 ---
      const groupSheet = ss.getSheetByName(SsCfgDoPost.SHEETS.GROUP);
      const cols = SsCfgDoPost.COLS.GROUP;
      const colIdx = {
        ID: SsCfgDoPost.colToNumb(cols.ID) - 1,
        NAME: SsCfgDoPost.colToNumb(cols.NAME) - 1
      };
      let groupNameMap = {};

      if (groupSheet && groupSheet.getLastRow() > 1) {
        const maxCol = Math.max(colIdx.ID, colIdx.NAME) + 1;
        const groupList = groupSheet.getRange(2, 1, groupSheet.getLastRow() - 1, maxCol).getValues();
        groupNameMap = groupList.reduce((map, row) => {
          map[row[colIdx.ID]] = row[colIdx.NAME];
          return map;
        }, {});
      }

      // --- ログデータの整形 ---
      const logsToWrite = this.logBuffer.map(log => {
        const groupName = log.sourceId ? (groupNameMap[log.sourceId] || log.sourceId) : 'N/A';
        const rowData = [log.timestamp, groupName, log.message];
        if (log.data) {
          rowData.push(log.data);
        }
        return rowData;
      });

      const debugSheet = ss.getSheetByName(SsCfgDoPost.SHEETS.DEBUG);
      if (!debugSheet) return;

      const startRow = debugSheet.getLastRow() + 1;
      const numRows = logsToWrite.length;
      const maxCols = logsToWrite.reduce((max, row) => Math.max(max, row.length), 0);

      if (numRows > 0) {
        // 全ての行が同じ列数になるように空文字列で埋める
        const paddedLogs = logsToWrite.map(row => {
          while (row.length < maxCols) row.push('');
          return row;
        });
        debugSheet.getRange(startRow, 1, numRows, maxCols).setValues(paddedLogs);
      }
    } catch (e) {
      // ログ書き込み自体でエラーが出た場合はconsole.errorを使用(無限ループ防止)
      console.error('ログのスプレッドシートへの書き込み中にエラーが発生しました:', e);
    } finally {
      this.logBuffer = []; // バッファをクリア
      lock.releaseLock();
    }
  },

  /**
   * ログバッファをScriptPropertiesに一時保存し、バッファをクリアする(非同期処理用)
   * - doPost内でスプレッドシート書き込みを避けるため、ログを後段処理に委譲
   * - 高速化によりLINEのタイムアウトを回避
   */
  queueLogs_() {
    if (this.logBuffer.length === 0) return;

    try {
      const uuid = Utilities.getUuid();
      const queueKey = '未処理ログ:' + uuid;

      // ログバッファをJSON化してScriptPropertiesに保存
      const logData = JSON.stringify(this.logBuffer);
      PropertiesService.getScriptProperties().setProperty(queueKey, logData);

      // バッファをクリア
      this.logBuffer = [];

    } catch (e) {
      // ScriptProperties保存に失敗した場合はconsole.errorで記録
      console.error('ログキューイング中にエラーが発生しました:', e);
    }
  },

  /**
   * ScriptPropertiesに保存された未処理ログを取得してスプレッドシートに書き込む
   * - processQueuedEvents_から呼び出される
   * - 既存のflushLogs_()ロジックを活用してログを処理
   */
  flushQueuedLogs_() {
    try {
      const props = PropertiesService.getScriptProperties();
      const allKeys = props.getKeys() || [];

      // 「未処理ログ:」で始まるキーを全て取得
      const logKeys = allKeys.filter(k => k.indexOf('未処理ログ:') === 0);

      if (logKeys.length === 0) return;

      // 各ログキューを処理
      for (const key of logKeys) {
        const logDataStr = props.getProperty(key);
        if (!logDataStr) {
          // 破損したキーは削除してスキップ
          props.deleteProperty(key);
          continue;
        }

        try {
          // JSON文字列をログ配列に復元
          const queuedLogs = JSON.parse(logDataStr);

          // 現在のバッファに追加(既存のflushLogs_()で処理するため)
          this.logBuffer.push(...queuedLogs);

        } catch (parseErr) {
          console.error('ログキューのパース中にエラーが発生しました:', parseErr);
        } finally {
          // 処理済みキーを削除
          props.deleteProperty(key);
        }
      }

      // 復元したログを既存のflushLogs_()で一括処理
      if (this.logBuffer.length > 0) {
        this.flushLogs_();
      }

    } catch (e) {
      console.error('未処理ログの処理中にエラーが発生しました:', e);
    }
  }
};


/**
 * =================================================================
 * 🔵 補助関数(メンテナンス・デバッグ用)
 * =================================================================
 */

/**
 * DebugLogシートを定期メンテナンスする(トリガーでの実行を想定)
 * - ログが1000行を超えていた場合、古いものから削除して最新1000行を保持する
 */
function logMaintenance() {

  // 消そうとしてるときに他のイベントが発生してログを書き込もうとして競合してしまうのを防ぐためにロックを取得
  const lock = LockService.getScriptLock();

  if (!lock.tryLock(30000)) return; // 30秒待ってもロックが取得できない場合は、タイムアウトとして処理を中断

  try {
    DoPostLogger.logForDebug_('ログメンテナンス処理を開始します。');
    const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
    const sheet = ss.getSheetByName(SsCfgDoPost.SHEETS.DEBUG);
    if (!sheet) return;

    const totalRows = sheet.getLastRow();
    const headerRows = SsCfgDoPost.HEADER_ROWS.DEBUG;
    const dataRows = totalRows - headerRows;
    const MAX_ROWS = 1000; // 🔶 ここを変更するとログの最大行数を変更できる

    if (dataRows > MAX_ROWS) {
      const rowsToDelete = dataRows - MAX_ROWS;
      sheet.deleteRows(headerRows + 1, rowsToDelete);
      DoPostLogger.logForDebug_(`ログメンテナンスを実行し、古いログを ${rowsToDelete} 行削除しました。`);
    } else {
      DoPostLogger.logForDebug_(`ログは ${dataRows} 行で、上限に達していないため削除は行いませんでした。`);
    }
  } catch (e) {
    DoPostLogger.logForDebug_('ログメンテナンス処理中にエラーが発生しました。', { message: e.message, stack: e.stack });
  } finally {
    DoPostLogger.flushLogs_();
    lock.releaseLock();
  }
}


/**
 * 特定のグループ/ルームの名称を手動で取得・更新する
 * joinイベントが発火しなかった場合(1:1トークからグループ化した場合など)のリカバリー用
 */
function manualGroupNameGetter() {
  // ▼▼▼ 更新したいルーム or グループ IDを設定 ▼▼▼
  const TARGET_SOURCE_ID = 'CXXXXXXXXXXXXXX';
  // ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲

  // ※ ここでも三項演算子を使用。IDの先頭文字を見て、'group'か'room'かを判断している。
  // `startsWith()`は文字列が指定した文字で始まるかどうかをtrue/falseで返すメソッド。
  const sourceType = TARGET_SOURCE_ID.startsWith('C') ? 'group' : TARGET_SOURCE_ID.startsWith('R') ? 'room' : null;

  if (!sourceType) {
    Logger.log(`Error: 不正なID形式です。: ${TARGET_SOURCE_ID}`);
    return;
  }

  try {
    const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
    const sheet = ss.getSheetByName(SsCfgDoPost.SHEETS.GROUP);

    let sourceName = '複数人トークルーム';
    if (sourceType === 'group') {
      const res = WebhookHandler.fetchLineApi_(`group/${TARGET_SOURCE_ID}/summary`);
      sourceName = res.groupName || '(グループ名取得失敗)';
    }

    // 既存データ内を検索して更新、なければ追記
    const cols = SsCfgDoPost.COLS.GROUP;
    const idColNum = SsCfgDoPost.colToNumb(cols.ID);
    const nameColNum = SsCfgDoPost.colToNumb(cols.NAME);

    const idColValues = sheet.getRange(2, idColNum, sheet.getLastRow() - 1, 1).getValues().flat();
    const rowIndex = idColValues.findIndex(id => id === TARGET_SOURCE_ID);

    if (rowIndex !== -1) {
      const rowToUpdate = rowIndex + 2;
      sheet.getRange(rowToUpdate, nameColNum).setValue(sourceName);
      Logger.log(`ID [${TARGET_SOURCE_ID}] の名前を「${sourceName}」に更新しました。`);
    } else {
      const newRowData = [];
      newRowData[idColNum - 1] = TARGET_SOURCE_ID;
      newRowData[nameColNum - 1] = sourceName;
      sheet.appendRow(newRowData);
      Logger.log(`ID [${TARGET_SOURCE_ID}] を「${sourceName}」として新規登録しました。`);
    }
  } catch (err) {
    Logger.log('処理中にエラーが発生しました: ' + err.message);
  }
}

/**
 * 不要なprocessQueuedEvents_トリガーを一括削除する(メンテナンス用)
 * - トリガーが蓄積してしまった場合の緊急対応用
 * - 手動実行でトリガーとキャッシュを強制クリーンアップ
 */
function manualCleanupProcessTrigs() {
  const triggers = ScriptApp.getProjectTriggers();
  let deletedCount = 0;

  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === 'processQueuedEvents_') {
      ScriptApp.deleteTrigger(trigger);
      deletedCount++;
    }
  });

  console.log(`${deletedCount}個のprocessQueuedEvents_トリガーを削除しました。`);

  // ワーカー処理中フラグもクリア
  CacheService.getScriptCache().remove('ワーカー処理中');
  console.log('ワーカー処理中フラグもクリアしました。');

  // ログに記録
  DoPostLogger.logForDebug_(`手動トリガークリーンアップを実行: ${deletedCount}個のトリガーを削除`);
  DoPostLogger.flushLogs_();
}

/**
 * 指定したグループのメンバーキャッシュを強制的にクリアする
 * @param {string} groupId - キャッシュをクリアしたいグループ/ルームID
 */
function forceRefreshMemberCache() {
  const ss = SpreadsheetApp.openById(SsCfgDoPost.ID);
  const groupSheet = ss.getSheetByName(SsCfgDoPost.SHEETS.GROUP);

  if (!groupSheet || groupSheet.getLastRow() < 2) {
    console.log('キャッシュをクリアする対象のグループがGroupシートに登録されていません。');
    return;
  }

  // Groupシートから全てのグループ/ルームIDを取得
  const idColNum = SsCfgDoPost.colToNumb(SsCfgDoPost.COLS.GROUP.ID);
  const groupIds = groupSheet.getRange(2, idColNum, groupSheet.getLastRow() - 1, 1).getValues().flat().filter(id => id);

  if (groupIds.length === 0) {
    console.log('有効なグループIDがGroupシートに見つかりませんでした。');
    return;
  }

  const cache = CacheService.getScriptCache();

  // user_in_XXXXXってついてるのはaddMember_で作ったキャッシュだけ。webhookイベント重複防止とかのときに作った関係ないキャッシュは消さない。
  // グループIDをキーにして、グループごとにまとめてcacheKeysに入れる
  const cacheKeys = groupIds.map(oneGroupId => `user_in_${oneGroupId}`);

  // 該当するキャッシュを一括で削除
  cache.removeAll(cacheKeys);

  const message = `以下のグループ/ルームのメンバーキャッシュをクリアしました:\n- ${groupIds.join('\n- ')}`;
  console.log(message);
}


/**
 * ===================================
 * 🔵 テスト用のWebhookイベントを生成し、doPostを実行する関数
 * ===================================
 */
function testDoPost() {

  const testEventPayload = {
    "destination": "xxxxxxxxxx",
    "events": [
      {
        // === ▼▼▼ テストしたいイベントに合わせてここを編集 ▼▼▼ ===
        "type": "message", // "join", "memberJoined", "memberLeft", "message"

        "source": {
          "type": "group", // "group", "room"
          "groupId": "C4671a7cd55fe535efac11b8eba2172dd",
          // "roomId": "Rxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1",
          "userId": "Uf31befc343235c16dccec66d74f1733e"
        },
        "timestamp": Date.now(),
        "mode": "active", // LINE Messaging APIから送られてくるWebhookイベントの一部で、このイベントがアクティブなチャネルから送信されたことを示している。

        // ※ Utilities.getUuid() は、GASの標準機能で、UUID( universally unique identifier)と呼ばれる
        // 世界中でほぼ重複することのない一意な文字列を生成する。テストデータでユニークなIDが必要な場合に便利。
        "webhookEventId": "01HAAAAA" + Utilities.getUuid(),
        "deliveryContext": { "isRedelivery": false },

        // --- messageイベント用 ---
        "message": {
          "type": "text",
          "id": "12345",
          "text": "こんにちは"
        },

        // --- joinイベント用 ---
        // (特に設定不要)

        // --- memberJoinedイベント用 ---
        "joined": {
          "members": [
            { "userId": "Uyyyyyyyyyyyyyyyyyyyyyyyyyyyyy2" },
            { "userId": "Uyyyyyyyyyyyyyyyyyyyyyyyyyyyyy3" }
          ]
        },
        "replyToken": "dummyReplyToken" + Utilities.getUuid(),

        // --- memberLeftイベント用 ---
        "left": {
          "members": [
            { "userId": "Uyyyyyyyyyyyyyyyyyyyyyyyyyyyyy1" }
          ]
        }
        // === ▲▲▲ 編集はここまで ▲▲▲ ===
      }
    ]
  };

  // doPost関数が受け取るイベントオブジェクト`e`の形式を模倣
  const mockEvent = {
    postData: {
      contents: JSON.stringify(testEventPayload)
    }
  };

  Logger.log("--- testDoPost 開始 ---");
  Logger.log("送信する模擬イベント: %s", mockEvent.postData.contents);

  // メイン関数を実行
  doPost(mockEvent);

  Logger.log("--- testDoPost 終了 ---");
}

function doGet() { return ContentService.createTextOutput('OK'); }

Discussion