
Google Apps ScriptのWebアプリにより、SlackのチャンネルやDMにメンション投稿された質問に対して、Slack AppとGemini APIを使用して、リアルタイムに自動回答するボットアプリの作成例を紹介します。
- 操作環境:
- OS:Windows 11
- Webブラウザー:Google Chrome
- 使用プラン:Google Workspace Business Starter、
Slackフリープラン、Gemini API無料枠
- アプリの仕様・構成
- Gemini API設定
- Slack App設定
- Slack設定
- Googleドライブ 情報ファイル用意
- Google Apps Script用意
- Google Apps Scriptによる処理実装
- 動作確認
- おわりに(方式比較)
アプリの仕様・構成
前の記事と同様、SlackのチャンネルやDMでSlackアプリをメンション(厳密にはSlackアプリとの1:1のDMはメンション不要)して質問が投稿されると、Google Apps Script(以降GASと表記します)により、Googleドライブの特定ファイルの情報を元に、Gemini APIで回答を作成し、自動回答する機能を実装します。
前の記事では、GASを15分おきに定期実行し、Slackに対象の投稿があれば回答する仕様としていました(定期巡回型)。
今回は、定期巡回型からアプリの構成を大きく変えず、リアルタイムに回答する仕様とします。処理のタイムアウト(GASは6分)により回答できずに取りこぼしが発生する可能性があります。
また、前の記事の最後に言及した、質疑内容のログ出力や未解決質問の管理者通知についても実装します。

Gemini API設定
前の記事の工程2と同じ設定のため、そちらをご覧ください。
Slack App設定
前の記事の工程3と同じ設定に加え、Events API使用のため、[Socket Mode]の無効化と[Event Subscriptions]の設定が必要となります。[Event Subscriptions]の図表3-8の設定は、工程6の図表6-6のGASのWebアプリのURL登録が必要なため、手順が前後します。
Slackにサインインした後、Slack APIのYour Apps画面にアクセスし、[Create an App]を選択します。
[Blank app]を選択し、[Coutinue]を選択します。

アプリ名(メンション時に指定するボットの名前)を入力し、使用ワークスペースを選択し、[Create]を選択します。
左メニューの[App Home]を選択します。
[Messages Tab]のトグルをオンにし、[Allow users to send Slach commands and messages from the messages tab]のチェックをオンにします。これにより、チャンネル内やDM内でこのSlackアプリへのメンションを使用できるようになります。

左メニューの[OAuth & Permissions]を選択します。
画面中央部にある[スコープ>ボットトークンのスコープ]の[OAuthスコープを追加する]より、自動応答を使用したいチャンネルやDMに応じて以下を選択します。
- chat:write
- channels:history,channels:read(パブリックチャンネルで使用する場合)
- groups:history,groups:read(プライベートチャンネルで使用する場合)
- im:history,im:read(1:1DMで使用する場合)
- mpim:history,mpim:read(複数人DMで使用する場合)
- users:read(質疑内容のログ出力や未解決質問の管理者通知を行う場合)

図表3-3の画面の上部にある[OAuth Tokens]の[install to (ワークスペース名)]を選択します。
[許可する]を選択します。
なお、この設定を行うユーザーがワークスペースの管理者でない場合は、管理者に許可依頼が通知され、管理者の許可を待つ形となります。
処理が完了すると、OAuth Tokenが発行されます。[Copy]を選択し、値を控えておきます。この値は工程6のGASのスクリプトプロパティで設定します。

左メニューの[Socket Mode]を選択し、 [Enable Socket Mode]のトグルをオフにします。

左メニューの[Event Subscriptions]を選択し、[Enable Events]のトグルをオンにし、[Subscribe to bot events]を選択して展開します。

[Add Bot User Event]より、自動応答を使用したいチャンネルやDMに応じて以下を選択します。
- message.channels(パブリックチャンネルで使用する場合)
- message.groups(プライベートチャンネルで使用する場合)
- message.im(1:1DMで使用する場合)
- message.mpim(複数人DMで使用する場合)

画面上部の[Request URL]欄に工程6の図表6-6で控えたGASのWebアプリのURLをペーストします。
[Verified]と表示されれば問題ありません。右下の[Save Changes]を選択します。

Slack設定
前の記事の工程4と同じ設定(*)に加え、管理者通知用チャンネルを用意し、工程3のSlackアプリを招待しておきます。
管理者通知用チャンネルにメンバー追加と同じ要領で、「/invite @{Slackアプリ名}」で招待できます。
併せて、URLの末尾のチャンネルID(Cxxxxxxxxxx)を控えておきます。この値は工程6のGASのスクリプトプロパティ内で使用します。

Googleドライブ 情報ファイル用意
前の記事の工程5と同じ設定(手順はそちらをご参照)に加え、Googleドライブにログ出力用のスプレッドシートを配置します。
この例では、共有ドライブの[IT管理]の下に[user_manual_and_FAQ]フォルダーの下に、ログ出力用のスプレッドシートを配置します。

図表5-2はログ出力用スプレッドシートの内容です。1行目に項目名を入力しておきます。2行目以降はサンプルなのでブランクで問題ありません。
併せて、URL欄の「・・・/d/xxxxxx/edit?・・・」のxxxxxx部分の値(ファイルID)を控えておきます。工程6のGASのスクリプトプロパティで使用します。

Google Apps Script用意
WebブラウザーにてGASのホーム画面にアクセスし、[新しいプロジェクト]を選択します。
[無題のプロジェクト]を選択し、プロジェクト名を分かり易い名称に変更します。
左端の[プロジェクトの設定]アイコンを選択します。

画面下部の[スクリプトプロパティを追加]を選択します。
以下の通り設定し、[スクリプトプロパティを保存]を選択します。
- admin_channel_id:工程4の図表4-1で控えたチャンネルIDの値
- faq_spreadsheet_id:前の記事の工程5の図表5-3で控えたファイルIDの値
- gemini_api_key:前の記事の工程2の図表2-2で控えたAPIキーの値
- log_spreadsheet_id:工程5の図表5-2で控えたファイルIDの値
- pdf_folder_id:前の記事の工程5の図表5-1で控えたフォルダーIDの値
- slack_bot_token:工程3の図表3-4で控えたOAuth Tokenの値

スクリプトエディターのコード.gsにスクリプトを記述していきます(スクリプトの内容は工程7で紹介します)。

Webアプリとして初回デプロイを行います。
右上の[デプロイ>新しいデプロイ]を選択します。
設定アイコンを選択し、[ウェブアプリ]を選択します。
[新しいデプロイ]画面で以下の通り設定し、[デプロイ]を選択します。
- 次のユーザーとして実行:自分
- アクセスできるユーザー:全員

データアクセスに関する確認メッセージが表示されたら、[アクセスを承認]を選択します。
アプリ承認に関する確認メッセージが表示されたら、[Select all]にチェックをつけ、[Continiue]を選択します。

デプロイが完了すると、WebアプリのURLが表示されます。
このURLの[コピー]を選択し、工程3の図表3-8のURL欄に設定します。

スクリプトを変更した場合、アプリにそれを反映させるため、再度デプロイを行う必要があります。
右上の[デプロイ>デプロイを管理]を選択します。
右上の編集アイコンを選択します。
バージョン欄で[新バージョン]を選択し、[デプロイ]を選択します。

デプロイ時にアクセスユーザーで「公開」の選択肢が表示されない場合、Google管理コンソールで設定を変更する必要があります。
Google管理コンソールの左メニューの[アプリ>Google Workspace>ドライブとドキュメント]を選択し、適用対象の組織部門を選択し(*)、[共有設定>共有オプション]の[{組織名}の外部との共有]で[オン]を選択します。

Google Apps Scriptによる処理実装
スクリプトが長いため見出しをメイン関数とサブ関数に分けますが、どちらもコード.gs内に記述します。
doPost, メイン関数
ポイント事項は以下の通りです。
/**
* GoogleDriveのFAQやマニュアルPDFを元に、Slackに投稿された質問に自動回答するBot
*/
/**
* Slackから通知を受取り、同一処理内でAI回答の作成・返信を実行する
*/
function doPost(e) {
const json = JSON.parse(e.postData.contents);
// URL検証(Challenge)への即時応答
if (json.type === 'url_verification') {
return ContentService.createTextOutput(json.challenge);
}
// Slackのリトライ(再送)ヘッダーをチェックして重複実行を拒否
// (Slack APIは3秒以内に応答がないと3回再送する仕様のため)
if (e.parameter && e.parameter['X-Slack-Retry-Num']) {
console.log(`リトライリクエストのため無視します (Retry: ${e.parameter['X-Slack-Retry-Num']})`);
return ContentService.createTextOutput("OK");
}
if (json.event) {
const event = json.event;
// 重複イベントをチェックする(CacheServiceを利用)
const cache = CacheService.getScriptCache();
const eventId = json.event_id || (event.user + "_" + event.ts);
if (cache.get(eventId)) {
console.log(`重複イベントのため処理をスキップします: ${eventId}`);
return ContentService.createTextOutput("OK");
}
// 5分間キャッシュに保持して重複処理を防止する
cache.put(eventId, "processed", 300);
// Bot自身の投稿は即時スキップする
if (event.bot_id || event.subtype) {
return ContentService.createTextOutput("OK");
}
// メイン処理を実行
handleSlackEvent(event); //★メイン関数呼出
}
return ContentService.createTextOutput("OK");
}
/**
* メイン関数
*/
function handleSlackEvent(event) {
const botToken = PropertiesService.getScriptProperties().getProperty('slack_bot_token');
const FAQ_SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('faq_spreadsheet_id');
const PDF_FOLDER_ID = PropertiesService.getScriptProperties().getProperty('pdf_folder_id');
const LOG_SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('log_spreadsheet_id');
const ADMIN_CHANNEL_ID = PropertiesService.getScriptProperties().getProperty('admin_channel_id');
// Bot自身のユーザーID(Uxxxxxxx)を取得する
const botUserId = getBotUserId(botToken);//★サブ関数呼出
if (!botUserId) {
console.error("BotのユーザーIDが取得できませんでした。");
return;
}
// 投稿された会話の種類をチェックする
const channelId = event.channel;
const channelType = event.channel_type;
const isDm = channelType === 'im' || channelType === 'mpim';
// 投稿された会話の種類がチャンネルの場合:許可リストチェック
if (!isDm) {
//対象チャンネルのIDを指定する(パブリック/プライベートは問わない)
const ALLOWED_CHANNEL_IDS = ['Cxxxxxxxxxx', 'Cxxxxxxxxxx'];
if (!ALLOWED_CHANNEL_IDS.includes(channelId)) return;
}
// メンションのないチャンネル投稿は対象外とする
const mentionTag = `<@${botUserId}>`;
const isMentioned = isDm || (event.text && event.text.includes(mentionTag));
if (!isMentioned) return;
// メンションタグを除去して質問文を抽出する
let userQuery = event.text ? event.text.replace(new RegExp(mentionTag, 'g'), '').trim() : '';
if (!userQuery) return;
// スレッドの親IDを取得する(親メッセージがない場合は自身のts)
const threadTs = event.thread_ts || event.ts;
let historyText = "";
// 親メッセージと異なる場合(=スレッド内の子返信の場合)に会話履歴を作成する
if (event.thread_ts) {
const threadMessages = getThreadMessages(channelId, event.thread_ts, botToken);//★サブ関数呼出
// 最新の質問以外の過去ログ(ユーザーの発言とBotの回答)を抽出してテキスト化する
const pastMsgs = threadMessages.filter(m => Number(m.ts) < Number(event.ts));
historyText = pastMsgs.map(m => {
const sender = m.bot_id ? "Bot" : "ユーザー";
const cleanText = m.text ? m.text.replace(new RegExp(mentionTag, 'g'), '').trim() : '';
return `${sender}: ${cleanText}`;
}).join("\n");
}
try {
// Googleドライブの情報読込(FAQスプレッドシート、ユーザーマニュアルPDF)
const cachedFaqText = getSpreadsheetText(FAQ_SPREADSHEET_ID);//★サブ関数呼出
const cachedPdfBlobs = getPdfBlobsFromFolder(PDF_FOLDER_ID);//★サブ関数呼出
// Gemini API にテキスト(FAQ)と複数PDF、質問を渡して回答を得る
const answer = askGemini(userQuery, historyText, cachedFaqText, cachedPdfBlobs);//★サブ関数呼出
//Slackのスレッドに回答を投稿する
postToSlackThread(channelId, threadTs, answer, botToken);//★サブ関数呼出
// ユーザー名・チャンネル名の取得(ログ記録・管理者通知用)
const isUnresolved = answer.includes("別途担当者より回答いたします");
const userName = getSlackUserName(event.user, botToken);//★サブ関数呼出
const locationName = isDm ? "1:1 DM" : getSlackChannelName(channelId, botToken);//★サブ関数呼出
// ログスプレッドシートへ全件ログを書き込み
if (LOG_SPREADSHEET_ID) {
try {
const sheet = SpreadsheetApp.openById(LOG_SPREADSHEET_ID).getActiveSheet();
sheet.appendRow([
new Date(), // A列: 投稿日時
locationName, // B列: チャンネル名または DM
userName, // C列: 質問者の表示名
userQuery, // D列: 質問内容
answer, // E列: Botの回答
isUnresolved ? "要対応" : "完了", // F列: ステータス
channelId, // G列: チャンネルID
event.user // H列: ユーザーID
]);
} catch (logErr) {}
}
// 未解決の場合のみ管理者チャンネルへ通知
if (isUnresolved && ADMIN_CHANNEL_ID) {
const noticeText = `⚠️ *未解決の問い合わせが発生しました*\n` +
`・*場所*: ${locationName}\n` +
`・*質問者*: ${userName} (<@${event.user}>)\n` +
`・*質問内容*:\n> ${userQuery.replace(/\n/g, '\n> ')}`;
postToSlackThread(ADMIN_CHANNEL_ID, "", noticeText, botToken);
}
} catch (e) {
console.error(`全体エラー: ${e.toString()}`);
}
}
サブ関数
getBotUserId, getThreadMessages, getSpreadsheetText, getPdfBlobsFromFolder, askGemini, postToSlackThreadについては前の記事と同じため、そちらの説明をご覧ください。
その他のサブ関数のポイント事項は以下の通りです。
/**
* Bot自身のユーザーID(Uxxxxxxx)を取得する関数
*/
function getBotUserId(token) {
const url = 'https://slack.com/api/auth.test';
const response = UrlFetchApp.fetch(url, {
method: 'post',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = JSON.parse(response.getContentText());
return data.ok ? data.user_id : null;
}
/**
* スレッド内の全メッセージ履歴を取得する関数
*/
function getThreadMessages(channelId, threadTs, token) {
const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`;
try {
const res = UrlFetchApp.fetch(url, {
method: 'get',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = JSON.parse(res.getContentText());
return data.ok ? data.messages : [];
} catch (e) {
console.error("スレッド履歴取得エラー:", e.toString());
return [];
}
}
/**
* スプレッドシートの内容を取得して文字列テキストに変換する関数
*/
function getSpreadsheetText(spreadsheetId) {
const ss = SpreadsheetApp.openById(spreadsheetId);
const sheet = ss.getSheets()[0];
const values = sheet.getDataRange().getValues();
return values.map(row => row.join("\t")).join("\n");
}
/**
* 指定したGoogle Driveフォルダー内の全PDFファイルのBlobを取得する関数
*/
function getPdfBlobsFromFolder(folderId) {
const folder = DriveApp.getFolderById(folderId);
const files = folder.getFilesByType(MimeType.PDF);
const pdfBlobs = [];
while (files.hasNext()) {
pdfBlobs.push(files.next().getBlob());
}
if (pdfBlobs.length === 0) {
console.warn("警告: 指定されたフォルダーにPDFファイルが見つかりませんでした。");
}
return pdfBlobs;
}
/**
* Gemini API にテキスト(FAQ)と複数PDF、質問を渡して回答を得る関数
*/
function askGemini(query, historyText, faqText, pdfBlobs) {
const apiKey = PropertiesService.getScriptProperties().getProperty('gemini_api_key');
if (!apiKey) {
throw new Error("スクリプトプロパティ 'gemini_api_key' が設定されていません。");
}
const modelName = "gemini-3.6-flash";
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
const systemInstruction =
"あなたは社内ヘルプデスクのFAQ回答Botです。\n" +
"提供された【FAQデータ】および添付された【複数のマニュアルPDF】の情報のみに基づいて、ユーザーからの質問にわかりやすく丁寧に変更・回答してください。\n" +
"スレッド内での追加質問の場合は【これまでの会話履歴】を踏まえて回答してください。\n" +
"資料に記載がない内容については、無理に答えず「別途担当者より回答いたします。」と回答してください。";
// 会話履歴がある場合はプロンプトに含める
let userPrompt = `【FAQデータ】\n${faqText}\n\n`;
if (historyText) {
userPrompt += `【これまでの会話履歴】\n${historyText}\n\n`;
}
userPrompt += `【最新の質問】\n${query}`;
const parts = [];
// PDFファイルをパーツに追加
pdfBlobs.forEach(pdfBlob => {
parts.push({
"inline_data": {
"mime_type": "application/pdf",
"data": Utilities.base64Encode(pdfBlob.getBytes())
}
});
});
parts.push({
"text": `${systemInstruction}\n\n${userPrompt}`
});
const options = {
"method": "post",
"contentType": "application/json",
"payload": JSON.stringify({
"contents": [{ "parts": parts }],
"generationConfig": { "temperature": 0.2 }
}),
"muteHttpExceptions": true
};
const response = UrlFetchApp.fetch(url, options);
const statusCode = response.getResponseCode();
const json = JSON.parse(response.getContentText());
if (statusCode === 200) {
return json.candidates[0].content.parts[0].text;
}
throw new Error(`Gemini API エラー (${statusCode}): ${JSON.stringify(json)}`);
}
/**
* Slackのスレッドに回答を投稿する関数
*/
function postToSlackThread(channelId, threadTs, text, token) {
const url = 'https://slack.com/api/chat.postMessage';
const payload = {
channel: channelId,
thread_ts: threadTs,
text: text
};
UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json; charset=utf-8',
headers: { 'Authorization': `Bearer ${token}` },
payload: JSON.stringify(payload)
});
}
/**
* ユーザーID(Uxxxx)から表示名(DisplayNameまたはRealName)を取得する関数
*/
function getSlackUserName(userId, token) {
if (!userId) return "不明";
const url = `https://slack.com/api/users.info?user=${userId}`;
try {
const res = UrlFetchApp.fetch(url, {
method: 'get',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = JSON.parse(res.getContentText());
if (data.ok && data.user) {
return data.user.profile.display_name || data.user.profile.real_name || data.user.name;
}
} catch (e) {
console.error("ユーザー名取得エラー:", e.toString());
}
return userId;
}
/**
* チャンネルID(Cxxxx)からチャンネル名を取得する関数
*/
function getSlackChannelName(channelId, token) {
if (!channelId) return "不明";
const url = `https://slack.com/api/conversations.info?channel=${channelId}`;
try {
const res = UrlFetchApp.fetch(url, {
method: 'get',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = JSON.parse(res.getContentText());
if (data.ok && data.channel) {
return `#${data.channel.name}`;
}
} catch (e) {
console.error("チャンネル名取得エラー:", e.toString());
}
return channelId;
}
動作確認
ユーザーがSlackアプリをメンションして(厳密にはSlackアプリとの1:1のDMはメンション不要)質問すると、15分おきに定期実行しているGASにより、返信にて自動回答されます。
図表8-1は1:1のDMに投稿した質問の自動回答例です。
この例では工程5で準備したGoogleドライブ内のファイルに情報があったため、詳しい操作手順が回答されています。

図表8-2はSlackアプリが招待されたチャンネルに投稿した質問の自動回答例です。
この例では工程5で準備したGoogleドライブ内のファイルに情報がなかったため、担当者から別途回答することとしています。

図表8-2のようにGoogleドライブ内のファイルに情報がなく担当者から別途回答とされた場合、図表8-3のように管理者通知用チャンネルに通知されます。

ここでは割愛しますが、スプレッドシートへの質疑内容のログ出力は工程5の図表5-2をご参照ください。
おわりに(方式比較)
前の記事で紹介した定期実行型との違いを下表にまとめます。
| 比較項目/方式 | 定期巡回型 | リアルタイム型 |
|---|---|---|
| 回答のタイムラグ | ×(あり) | ○(なし) |
| 失敗時再実行 | ○(あり) | ×(なし) |
| GWS外部共有許可 | ○(不要) | ×(必要:工程6の図表6-8) |
| GASメンテナンス性 | ×(*表の下の注記参照) | ○ |
どちらの方式でも一度のGASの処理で6分超かかるとタイムアウトするため、回答の材料となる情報が大量にある場合や質問が多発して負荷が集中する場合には向いていません。GASの6分タイムアウトに抵触する場合は、GASではなくGoogle Cloudを使用する等、別のアプリ構成での実装が必要になります。
当ブログ内の関連記事