Slackのチャンネルで、ユーザーの質問が放置されてしまうことってありませんか?
「誰か答えてあげて…」と思いながら流れていくスレッド。
そんな 「返事が来てない質問スレッド」を自動検出して、情シスに通知するSlack Bot を作ります。
しかも、コードはコピペOK。図解付き。初心者でも動く。
今回は Google Apps Script(GAS)で無料&サーバーレスで実現 します。
✅ やること
- 特定のSlackチャンネルを定期監視
- スレッドが放置されてるかどうかを判定
- 解決済みならスキップ
- 一定時間返事がなければ情シスメンバーにアラート
- スパム防止のためクールダウン時間も設定
🛠 必要なもの
- Googleアカウント(Gmailとか使ってればOK)
- Slackワークスペースの管理者権限(Botを作成・トークン発行のため)
- SlackのBotトークン(後述)
- GAS(Google Apps Script)の基本操作(貼り付けと実行だけでOK)
📦 スクリプト全体(コピペOK)
// ==========================================
// Slackの放置スレッド監視Bot(GAS)
// ==========================================
// チャンネルを監視して、一定時間返信がなければ管理者にアラート送信
// ✅リアクションが付いた投稿は「解決済み」とみなして通知しない
// ==========================================
const CONFIG = {
TARGET_CHANNEL_ID: 'C1234567890', // ★ここに監視したいチャンネルIDを入れる
ALERT_CHANNEL_ID: 'C0987654321', // ★ここに通知先チャンネルIDを入れる
ADMIN_USER_IDS: ['U111111', 'U222222'], // ★情シスメンバーのIDリスト
THRESHOLD_MINUTES: 60,
COOLDOWN_HOURS: 4,
RESOLVED_REACTION_NAME: 'white_check_mark',
MENTION_ADMINS: true
};
function checkNeglectedThreads() {
const token = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
if (!token) throw new Error('"SLACK_TOKEN" が設定されていません。');
const history = fetchChannelHistory(token, CONFIG.TARGET_CHANNEL_ID, 20);
if (!history || history.length === 0) return;
const now = Math.floor(Date.now() / 1000);
const thresholdSeconds = CONFIG.THRESHOLD_MINUTES * 60;
const cooldownSeconds = CONFIG.COOLDOWN_HOURS * 3600;
const scriptProps = PropertiesService.getScriptProperties();
history.forEach(msg => {
if (msg.subtype === 'bot_message' || msg.subtype === 'channel_join') return;
if (hasResolvedReaction(msg)) {
const cacheKey = 'alerted_' + msg.ts;
if (scriptProps.getProperty(cacheKey)) scriptProps.deleteProperty(cacheKey);
return;
}
let lastUser = msg.user;
let lastTs = msg.ts;
let threadTs = msg.ts;
if (msg.thread_ts && msg.reply_count > 0) {
const replies = fetchThreadReplies(token, CONFIG.TARGET_CHANNEL_ID, msg.thread_ts);
if (replies && replies.length > 0) {
const lastReply = replies[replies.length - 1];
lastUser = lastReply.user;
lastTs = lastReply.ts;
threadTs = msg.thread_ts;
}
}
const isAdmin = CONFIG.ADMIN_USER_IDS.includes(lastUser);
const elapsed = now - parseFloat(lastTs);
if (!isAdmin && elapsed > thresholdSeconds) {
const cacheKey = 'alerted_' + threadTs;
const lastAlertedTime = scriptProps.getProperty(cacheKey);
if (lastAlertedTime && (now - parseInt(lastAlertedTime)) < cooldownSeconds) return;
const elapsedMinutes = Math.floor(elapsed / 60);
sendAlert(token, threadTs, elapsedMinutes);
scriptProps.setProperty(cacheKey, now.toString());
}
});
}
function fetchChannelHistory(token, channelId, limit) {
const url = `https://slack.com/api/conversations.history?channel=${channelId}&limit=${limit}`;
return callSlackApi(token, url).messages || [];
}
function fetchThreadReplies(token, channelId, ts) {
const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${ts}`;
return callSlackApi(token, url).messages || [];
}
function sendAlert(token, ts, minutesElapsed) {
const permalink = getPermalink(token, CONFIG.TARGET_CHANNEL_ID, ts);
let text = `⚠️ *放置検知アラート* ⚠️\nユーザーの最後の発言から *${minutesElapsed}分* が経過しています。\n対応漏れはありませんか?\n${permalink}`;
if (CONFIG.MENTION_ADMINS) {
const mentions = CONFIG.ADMIN_USER_IDS.map(id => `<@${id}>`).join(' ');
text = `${mentions}\n${text}`;
}
const payload = {
channel: CONFIG.ALERT_CHANNEL_ID,
text: text,
unfurl_links: true
};
const options = {
method: 'post',
contentType: 'application/json',
headers: { Authorization: `Bearer ${token}` },
payload: JSON.stringify(payload)
};
UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
}
function getPermalink(token, channelId, ts) {
const url = `https://slack.com/api/chat.getPermalink?channel=${channelId}&message_ts=${ts}`;
const res = callSlackApi(token, url);
return res && res.ok ? res.permalink : '(リンク取得失敗)';
}
function hasResolvedReaction(msg) {
if (!msg.reactions) return false;
return msg.reactions.some(r => r.name === CONFIG.RESOLVED_REACTION_NAME);
}
function callSlackApi(token, url) {
const options = {
method: 'get',
headers: { Authorization: `Bearer ${token}` },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const json = JSON.parse(response.getContentText());
if (!json.ok) {
console.error(`Slack API Error: ${json.error}`, url);
return {};
}
return json;
}
① Slack Botトークンを取得する
Slack APIページでBotを作ってトークンを取得します。
-
「Create New App」から新しいアプリを作成
-
以下のOAuthスコープを付与(Bot Token Scopes):
chat:writechannels:historychannels:readreactions:readusers:read
-
ワークスペースにインストールして、Bot Token(
xoxb-...)を取得
② GASプロジェクトを作る
- https://script.google.com にアクセスして「新しいプロジェクト」を作成
- エディタにコードを貼り付け
- メニュー → ファイル → プロジェクトのプロパティ → スクリプトのプロパティ を開く
- キー:
SLACK_TOKEN、値:取得したトークン を登録
③ 設定項目を編集する
const CONFIG = {
TARGET_CHANNEL_ID: '監視したいチャンネルID',
ALERT_CHANNEL_ID: '通知を飛ばすチャンネルID',
ADMIN_USER_IDS: ['UXXXXXXX1', 'UXXXXXXX2'], // 情シスメンバーのユーザーID
THRESHOLD_MINUTES: 60, // 何分放置されたら通知するか
COOLDOWN_HOURS: 4, // 同じスレッドに再通知しないクールダウン
RESOLVED_REACTION_NAME: 'white_check_mark', // ✅ で解決済みとみなす
MENTION_ADMINS: true // 通知にメンションをつけるか
};
SlackのチャンネルIDやユーザーIDは、開発者モードで右クリックすると取得可能です。
④ トリガーの設定(定期実行)
GASの画面で
メニュー → トリガー → 新しいトリガーを追加
関数名:checkNeglectedThreads
イベントの種類:時間主導型
→ 「5分おき」「15分おき」などで実行
⑤ ✅ 解決済みの扱い
Slackで対象メッセージに ✅(white_check_mark)リアクションを付けるだけで通知対象外になります。
Botの動作を止めるのも再開するのも手動でコントロール可能。
🔥 どういう時に通知される?
✅が付いてない & 以下すべてに該当するときに通知されます。
- 最後に返信したのが情シスメンバー以外
- 最終投稿から 60分以上放置
- 同じスレッドに 4時間以内に通知してない
🧠 補足:なぜ便利?
- 「対応漏れ」が起きなくなる
- ✅で解決管理できるので運用負荷が少ない
- GASで完結 → 無料で運用コストゼロ
- 管理者だけに通知できるのでノイズにならない
📌 注意点
- Slack API呼び出し制限に注意(多いと失敗する)
-
limitで読み込むメッセージ数は調整可能 -
thread_tsの扱いはスレッドの仕様によって異なるので注意
🎉 おわりに
Botって難しそうに見えるけど、ちゃんと構成決めて作れば超シンプル
Slackの質問が放置されがちなチームに導入するとめっちゃ便利です。
✍️ おまけ:コードで工夫してるポイント
- ✅リアクションを活用して「解決済み」を自動判定
- 最終発言者が「情シスメンバー以外」かどうかで判断
- クールダウン機能でスパム通知防止(プロパティで簡単キャッシュ)
- Slackのパーマリンク付きで、スレッドにすぐ飛べる