Portfolio

  1. /**
  2.  * LINE Messaging APIからのWebhookリクエストを処理する。
  3.  * 画像メッセージを受信し、ChatGPT Visionで品名と特徴を特定後、
  4.  * 横浜市ルールスプレッドシートの中からキーワード検索(全一致・部分一致)でルールを選択し分別情報を返す。
  5.  * 該当候補が複数ある場合は品目名をリストで提示後、選択ボタンを送信する。見つからない場合はその旨を伝える。
  6.  * 返信には参照したスプレッドシートの行番号(該当する場合)と検索方法を含める。
  7.  * 実行完了時(成功・エラー問わず)に指定メールアドレスにも**実行全体のログ**と**使用AIモデル・トークン数(Vision APIのみ)**を送信する。
  8.  * * 使用AIモデルについて:
  9.  * OpenAIのモデル名を "gpt-4o" に設定しています (画像認識用)。
  10.  * * 更新: キーワード検索で複数候補が見つかった場合、品目名リストと選択ボタンを送信するよう変更しました。
  11.  * * 更新: ポストバックイベント処理のデバッグのためログを強化しました。
  12.  * @param {Object} e - Webhookイベントオブジェクト。
  13.  */
  14. function doPost(e) {
  15.   if (e && e.postData && e.postData.contents) {
  16.     Logger.log("Webhook received. Raw postData: " + e.postData.contents);
  17.   } else {
  18.     Logger.log("Webhook received, but postData or contents are missing.");
  19.     return ContentService.createTextOutput(JSON.stringify({ "status": "error", "message": "No postData received" })).setMimeType(ContentService.MimeType.JSON);
  20.   }
  21.   Logger.log("doPost関数が開始されました。");
  22.   try {
  23.     const events = JSON.parse(e.postData.contents).events;
  24.     Logger.log(`受信イベント数: ${events.length}`);
  25.     for (const event of events) {
  26.       handleLineEvent_(event);
  27.     }
  28.   } catch (error) {
  29.     Logger.log(`doPostでエラーが発生しました: ${error.message}\nスタックトレース: ${error.stack}\n受信データ: ${e.postData.contents}`);
  30.   }
  31.   return ContentService.createTextOutput(JSON.stringify({ "status": "success" })).setMimeType(ContentService.MimeType.JSON);
  32. }
  33. /**
  34.  * LINEイベントを処理する。
  35.  * @param {Object} event - LINE Webhookイベントオブジェクト。
  36.  */
  37. function handleLineEvent_(event) {
  38.   Logger.log(`処理中のイベントタイプ: ${event.type}, イベント内容: ${JSON.stringify(event)}`);
  39.   const cache = CacheService.getScriptCache();
  40.   let eventId = null;
  41.   let eventTypeForCacheLog = event.type;
  42.   if (event.webhookEventId) {
  43.     eventId = event.webhookEventId;
  44.     eventTypeForCacheLog = `webhook (${event.type})`;
  45.     Logger.log(`Webhook Event ID (webhookEventId): ${eventId}`);
  46.   } else if (event.deliveryContext && event.deliveryContext.isRedelivery) {
  47.      Logger.log(`再送イベントを検出。UserID: ${event.source.userId}, Timestamp: ${event.timestamp}。処理をスキップします。`);
  48.      return;
  49.   } else if (event.message && event.message.id) {
  50.     eventId = event.message.id;
  51.     eventTypeForCacheLog = `message (${event.message.type})`;
  52.     Logger.log(`Message Event ID (message.id): ${eventId}`);
  53.   } else if (event.postback && event.postback.data) {
  54.     // ポストバックイベントの場合、dataとtimestampとuserIdからユニークなIDを生成する
  55.     const postbackDigest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, event.postback.data).map(b => (b + 256).toString(16).slice(-2)).join('');
  56.     eventId = `postback-${event.source.userId}-${event.timestamp}-${postbackDigest}`;
  57.     eventTypeForCacheLog = `postback (datahash: ${postbackDigest}, timestamp: ${event.timestamp})`;
  58.     Logger.log(`ポストバックイベントの生成イベントID: ${eventId}`);
  59.   } else {
  60.     Logger.log(`イベントIDを特定できませんでした。イベントタイプ: ${event.type}`);
  61.   }
  62.   // イベントIDが取得できた場合のみ重複チェックを行う
  63.   if (eventId) {
  64.     const processed = cache.get(eventId);
  65.     if (processed) {
  66.       Logger.log(`イベントID ${eventId} (${eventTypeForCacheLog}) は既に処理済みのためスキップします。キャッシュ値: ${processed}`);
  67.       return; // 既に処理済みの場合はここで終了
  68.     }
  69.     // 処理済みフラグをキャッシュに保存(有効期限10分)
  70.     const cacheValue = `processed_at_${new Date().toISOString()}_type_${eventTypeForCacheLog}`;
  71.     cache.put(eventId, cacheValue, 600); // 600秒 = 10分
  72.     Logger.log(`イベントID ${eventId} (${eventTypeForCacheLog}) をキャッシュに保存しました。Value: ${cacheValue}`);
  73.   } else {
  74.     Logger.log(`イベントIDが取得できなかったため (${eventTypeForCacheLog})、重複チェックをスキップします。`);
  75.   }
  76.   if (event.type === "message" && event.message) {
  77.     const userId = event.source.userId;
  78.     const message = event.message;
  79.     Logger.log(`メッセージイベント処理開始: UserID=${userId}, MessageType=${message.type}`);
  80.     if (message.type === "image") {
  81.       Logger.log(`画像メッセージ受信: messageId=${message.id}`);
  82.       processImageWithYokohamaSheetRules_(userId, message.id);
  83.     } else if (message.type === "text") {
  84.       Logger.log(`テキストメッセージ受信: ${message.text}`);
  85.       // テキストメッセージに対する応答が必要な場合はここに実装
  86.       // sendLineMessage_(userId, { type: "text", text: "ゴミの画像を送信すると、AIが分別方法を判断します!" });
  87.     } else {
  88.       Logger.log(`未対応のメッセージタイプです: ${message.type}`);
  89.     }
  90.   } else if (event.type === "postback") {
  91.     Logger.log("ポストバックイベントを handlePostbackEvent_ に渡します。");
  92.     handlePostbackEvent_(event);
  93.   } else {
  94.      Logger.log(`メッセージイベントでもポストバックイベントでもないためスキップします。タイプ: ${event.type}`);
  95.   }
  96. }
  97. /**
  98.  * ポストバックイベントを処理する。
  99.  * @param {Object} event - LINE Webhookポストバックイベントオブジェクト。
  100.  */
  101. function handlePostbackEvent_(event) {
  102.   Logger.log(`handlePostbackEvent_ が呼び出されました。イベント: ${JSON.stringify(event)}`);
  103.   const userId = event.source.userId;
  104.   const postbackData = event.postback.data;
  105.   Logger.log(`ポストバックデータ受信: UserID=${userId}, Data='${postbackData}'`);
  106.   // ポストバックデータをパース (例: "action=show_rule_details&row_number=123")
  107.   const params = new URLSearchParams(postbackData);
  108.   const action = params.get("action");
  109.   const rowNumberStr = params.get("row_number"); // 文字列として取得
  110.   if (action === "show_rule_details" && rowNumberStr) {
  111.     const rowNumber = parseInt(rowNumberStr, 10); // 10進数としてパース
  112.     if (isNaN(rowNumber)) {
  113.         Logger.log(`ポストバックエラー: row_number が数値ではありません: ${rowNumberStr}`);
  114.         sendLineMessage_(userId, { type: "text", text: "エラー:選択された情報が正しくありません。" });
  115.         return;
  116.     }
  117.     Logger.log(`アクション: show_rule_details, 行番号: ${rowNumber}`);
  118.     
  119.     const allRules = getYokohamaRulesFromSheet_(); // スプレッドシートから全ルールを取得
  120.     if (allRules) {
  121.       // 指定された行番号のルールを検索
  122.       const selectedRule = allRules.find(rule => rule.rowNumber === rowNumber);
  123.       if (selectedRule) {
  124.         Logger.log(`選択されたルール: ${JSON.stringify(selectedRule)}`);
  125.         // ルールの詳細情報をユーザーに送信
  126.         let detailMessage = `【横浜市ゴミ分別アドバイス】\n\n`;
  127.         detailMessage += `■ 品名 (ルール):\n${selectedRule.itemNameJP}\n\n`;
  128.         detailMessage += `■ ゴミの種類:\n${selectedRule.category}\n\n`;
  129.         detailMessage += `■ 出し方・注意点:\n${selectedRule.instruction || "特記事項なし"}\n\n`;
  130.         if (selectedRule.smallAppliance && selectedRule.smallAppliance !== "情報なし") {
  131.           detailMessage += `■ 小型家電回収:\n${selectedRule.smallAppliance}\n\n`;
  132.         }
  133.         detailMessage += `■ 収集頻度:\n横浜市の情報を確認してください\n`; // 収集頻度は固定文言
  134.         detailMessage += `(参照ルール: スプレッドシート ${selectedRule.rowNumber}行目)\n`;
  135.         detailMessage += `\n--------------------\n?? これは横浜市のスプレッドシートルールに基づく情報です。最新かつ詳細な公式情報は横浜市のウェブサイト等で必ずご確認ください。`;
  136.         sendLineMessage_(userId, { type: "text", text: detailMessage });
  137.       } else {
  138.         Logger.log(`指定された行番号 ${rowNumber} のルールが見つかりませんでした。`);
  139.         sendLineMessage_(userId, { type: "text", text: "選択されたルールの詳細情報が見つかりませんでした。" });
  140.       }
  141.     } else {
  142.       Logger.log("ポストバック処理中にルールシートの取得に失敗しました。");
  143.       sendLineMessage_(userId, { type: "text", text: "申し訳ありません、ルールの詳細情報を取得できませんでした。" });
  144.     }
  145.   } else {
  146.     Logger.log(`未対応のポストバックアクション、またはデータ不足: ${postbackData}`);
  147.     // 必要であればユーザーにエラーメッセージを送信
  148.   }
  149. }
  150. /**
  151.  * Script PropertiesからOpenAI APIキーを取得する。
  152.  * @return {string} OpenAI APIキー。
  153.  * @throws {Error} APIキーが未設定の場合。
  154.  */
  155. function getOpenAiApiKey_() {
  156.   const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  157.   if (!apiKey) throw new Error("OpenAI APIキーが未設定です。");
  158.   return apiKey;
  159. }
  160. /**
  161.  * LINEのContent APIから画像データをBlobとして取得する。
  162.  * @param {string} messageId - LINEメッセージID。
  163.  * @return {GoogleAppsScript.Base.Blob|null} 画像のBlobオブジェクト、または取得失敗時はnull。
  164.  * @throws {Error} LINEチャネルアクセストークンが未設定の場合。
  165.  */
  166. function getImageBlobFromLine_(messageId) {
  167.   const lineChannelAccessToken = PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_ACCESS_TOKEN');
  168.   if (!lineChannelAccessToken) throw new Error("LINEチャネルアクセストークンが未設定です。");
  169.   const contentApiUrl = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
  170.   const options = {
  171.     method: "get",
  172.     headers: { Authorization: "Bearer " + lineChannelAccessToken },
  173.     muteHttpExceptions: true // HTTPエラー時もレスポンス内容を取得するためtrue
  174.   };
  175.   const response = UrlFetchApp.fetch(contentApiUrl, options);
  176.   if (response.getResponseCode() === 200) {
  177.     return response.getBlob();
  178.   }
  179.   Logger.log(`LINE Content API画像取得失敗。Status: ${response.getResponseCode()}, Response: ${response.getContentText()}`);
  180.   return null;
  181. }
  182. /**
  183.  * Googleスプレッドシートから横浜市の分別ルールを取得する。
  184.  * スプレッドシートの1行目はヘッダー行としてスキップする。
  185.  * @return {Array<Object>|null} ルールオブジェクトの配列、または取得失敗時はnull。
  186.  * 各オブジェクトは { rowNumber: number, itemNameJP: string, smallAppliance: string, category: string, instruction: string, aliases: string[] } の形式。
  187.  */
  188. function getYokohamaRulesFromSheet_() {
  189.   Logger.log("getYokohamaRulesFromSheet_ 開始: 横浜市分別ルール(Sheet版 V4 行番号有)をGoogleスプレッドシートから読み込みます。");
  190.   const spreadsheetId = PropertiesService.getScriptProperties().getProperty('YOKOHAMA_RULES_SHEET_ID');
  191.   const sheetName = PropertiesService.getScriptProperties().getProperty('YOKOHAMA_RULES_SHEET_NAME'); // シート名をプロパティから取得
  192.   if (!spreadsheetId) {
  193.     Logger.log("getYokohamaRulesFromSheet_ エラー: スクリプトプロパティ 'YOKOHAMA_RULES_SHEET_ID' が未設定。");
  194.     return null;
  195.   }
  196.   try {
  197.     let ss = SpreadsheetApp.openById(spreadsheetId);
  198.     let sheet = sheetName ? ss.getSheetByName(sheetName) : ss.getSheets()[0]; // シート名指定があればそれを使用、なければ最初のシート
  199.     if (!sheet) {
  200.       Logger.log(`getYokohamaRulesFromSheet_ エラー: シート「${sheetName || '最初のシート'}」が見つかりません。`);
  201.       return null;
  202.     }
  203.     
  204.     // データ範囲の取得と空チェック
  205.     const dataRange = sheet.getDataRange();
  206.     if (!dataRange || dataRange.getNumRows() === 0 || dataRange.getNumColumns() === 0) {
  207.         Logger.log("getYokohamaRulesFromSheet_ エラー: getDataRange() で有効な範囲が取得できませんでした。");
  208.         return null;
  209.     }
  210.     const values = dataRange.getValues();
  211.     const rules = [];
  212.     // ヘッダー行を除いてデータがなければ空配列を返す
  213.     if (values.length < 2) {
  214.       Logger.log("getYokohamaRulesFromSheet_: スプレッドシートにデータがありません(ヘッダー行を除く)。");
  215.       return [];
  216.     }
  217.     // ヘッダー行をスキップ (i=1から開始)
  218.     for (let i = 1; i < values.length; i++) {
  219.       const row = values[i];
  220.       const rowNumber = i + 1; // スプレッドシート上の実際の行番号 (1始まり)
  221.       // 各列のデータを取得し、前後の空白をトリム。存在しない場合は空文字。
  222.       const itemNameJP =