GASで作ったLINE自動応答botのコード全文公開。任意の分数だけ遅延させられる機能つき
背景
仕事で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を置いて、その他の機能を別のファイルに置くことを提案された。
結果として以下のように分けることになった。
各ファイル(コード)構成
-
doPost.gs:
全体統括 -
ユーティリティ&全体イベント振り分け.gs:
APIのアクセストークン取得とか、LINEの実送信機能とかが置いてある。
また、今回は
顧客のフォローだったりスタンプ受信だったり
「テキストメッセージ受信」以外のイベントに反応する必要はなかったので、まず「テキスト受信かどうか」を判定する関数をここにおいた。 -
TextEventProcessors.gs:
発生したイベントがテキスト受信なら、ユーザーID取得と、必要に応じて自動応答。 -
AutoResponseProcessor(自動応答処理).gs:
キーワード応答処理
コード内容
1. doPost
/**
* 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. ユーティリティ系
/**
* =====================================================
* グループ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. 発生イベント振り分け
/**
* =====================================================
* グループ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つ。
- 自動応答(遅延&即時)
- ユーザーIDと各顧客名の紐づけ。
運用方法としては
客に電話番号を送ってもらい
顧客管理シート上の電話番号と突合して
「あ、このユーザーIDは鈴木さんか」みたいなことをやる。
これに関しては、ここに詳細書くと長くなるので、記事分けます。
記事執筆中
3-1. 振り分け処理
ここでは
// 電話番号判定
const shouldProcessPhone
以降は無視で大丈夫です。
/**
* 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列のキーワードが含まれているか否か、だけを判定させる。
// キーワードマッチングチェック
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は、意識高い感じのコードを書きたがるクセがあります。
今回も
// キーワードマッチングチェック
const matchedKeyword = await this.shouldProcessAutoResponse_(userInfo);
ここで
変数宣言&分離された関数の実行を、同時にやったりせず
自動応答シートからキーワード列(B列)を取得する処理をそのまま書けばいいだけなのに、わざわざ関数を分離されちゃいました。
4. 自動応答機能!(この記事の本題)
お待たせしました。
ここからがこの記事の本題です。
顧客が送信したメッセージに、応答すべきキーワードが入っていたら、この4つ目のgsファイル内の関数たちが走り始めます。
/**
* =====================================================
* 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_()あたり
const matchedKeyword = await this.shouldProcessAutoResponse_(userInfo);
↑ shouldProcessAutoResponse_の処理の短さを考えると、別に分ける必要はない。
const autoResponseResult = await AutoResponseProcessor.process_(userInfo);
return {
handled: true,
processor: 'キーワード応答',
result: autoResponseResult
};
↑ return以降の戻り値が他の箇所で使われずに捨てられているので、AutoResponseProcessorの処理をawaitする必要がない。
Discussion