🤖

Slackのメンションの返信忘れを防ぐためのGASを作った話

に公開

概要

最近色々なチャンネルでメンションされることが多く、何かをしている間に返信を忘れてしまうことが出てきたので、それを防ぐためのSlack&GASのツールをClaudeで作ってみました。
このツールは、Slackで自分へのメンションがあった場合に、それに返信しているかどうかを自動的にチェックします。返信漏れのメッセージがある場合、指定したチャンネルに通知を送ります。

できること

以下の仕様で自分へのメンションが来ていて、返信できていないものをリストアップして
特定のスラックチャンネルに通知を行います。

  1. 検出対象

    • 昨日以降の自分へのメンション (@ユーザー名) を検索
    • 自分自身が送信したメッセージは除外
  2. 未返信の判定

    • 親メッセージへのメンション: スレッド内に自分の返信があれば「返信済み」
    • スレッド内のメンション: そのメッセージの後にスレッド内で自分が返信していれば「返信済み」
  3. 通知形式

    • 未返信メッセージがある場合、指定したチャンネルに一覧が送信されます
    • 各メッセージのURLが含まれます(リンクプレビューなし)
    • 親メッセージのURLはクエリパラメータを含まない短い形式で表示

セットアップ手順

Slack appの作成から開設していきます!

1. Slack App の作成と設定

  1. Slack App を作成する

    • Slack APIサイト にアクセス
    • 「Create New App」をクリック
    • 「From scratch」を選択
    • アプリ名(例: "mention checker")とワークスペースを選択
  2. Bot User OAuth Tokens の設定

    • 左サイドバーの「OAuth & Permissions」をクリック
    • 「Scopes」セクションの「Bot Token Scopes」で以下の権限を追加:
      • channels:history
      • channels:read
      • chat:write
      • users:read
      • groups:history
      • groups:read
  3. User Token の設定

    • 「User Token Scopes」で以下の権限を追加:
      • search:read
      • channels:history
      • channels:read
      • groups:history
      • groups:read
      • mpim:history
      • im:history
  4. Workspace にインストール

    • 「OAuth & Permissions」ページ上部の「Install to Workspace」をクリック
    • 権限をレビューして「許可」をクリック
  5. トークンの取得

    • インストール後、「Bot User OAuth Token」と「User OAuth Token」が表示されます
    • これらのトークンを控えておきます(後で使用します)

2. Google Apps Script (GAS) の設定

  1. 新しいGoogle Apps Scriptプロジェクトを作成

    • Google Apps Script にアクセス
    • 「新しいプロジェクト」をクリック
    • プロジェクト名を設定(例: "SlackMentionChecker")
  2. コードの貼り付け
    -以下の内容をエディタに貼り付け

const main = async () => {
    try {
      const userToken = getProp("SLACK_USER_TOKEN");
      const botToken = getProp("SLACK_BOT_TOKEN");
      const channelId = getProp("SLACK_CHANNEL_ID");
      const myUserId = getProp("SLACK_MY_USER_ID");
  
      Logger.log("プロパティ取得完了");
  
      // 自分へのメンションを検索
      const mentions = await searchMentions(userToken, myUserId);
      Logger.log(`メンション検出数: ${mentions.length}`);
      
      const unreplied = [];
      for (const msg of mentions) {
        // URLに含まれるthread_tsパラメータを抽出
        const threadTsFromUrl = extractThreadTsFromUrl(msg.permalink);
        const isInThread = !!threadTsFromUrl && threadTsFromUrl !== msg.ts;
        
        // 使用するスレッドタイムスタンプを決定
        const threadTs = isInThread ? threadTsFromUrl : msg.ts;
        
        // メッセージに対するスレッド全体を取得
        const thread = await getThreadMessages(userToken, msg.channel.id, threadTs);
        
        // 対象メッセージのインデックスを特定
        const msgIndex = thread.findIndex(m => m.ts === msg.ts);
        
        let hasReplied = false;
        
        if (isInThread) {
          // スレッド内メッセージの場合
          if (msgIndex === -1) {
            // スレッド全体を取得し直す(念のため)
            const fullThread = await getThreadMessages(userToken, msg.channel.id, threadTs);
            
            // 対象メッセージを探す
            const targetIndex = fullThread.findIndex(m => m.ts === msg.ts);
            if (targetIndex !== -1) {
              // メッセージ以降の自分の返信を探す
              const laterMessages = fullThread.slice(targetIndex + 1);
              const myReplies = laterMessages.filter(m => m.user === myUserId);
              hasReplied = myReplies.length > 0;
            }
          } else {
            // 対象メッセージ以降の返信を確認
            const laterMessages = thread.slice(msgIndex + 1);
            const myReplies = laterMessages.filter(m => m.user === myUserId);
            hasReplied = myReplies.length > 0;
          }
        } else {
          // 親メッセージの場合
          const myReplies = thread.filter(m => m.user === myUserId && m.ts !== msg.ts);
          hasReplied = myReplies.length > 0;
        }
        
        if (!hasReplied) {
          // 未返信の場合、親メッセージは簡潔なURLを使用
          if (!isInThread) {
            // 親メッセージのURLからthread_tsパラメータを除去
            const cleanUrl = getCleanParentUrl(msg.permalink);
            unreplied.push({ url: cleanUrl });
          } else {
            unreplied.push({ url: msg.permalink });
          }
        }
      }
  
      Logger.log(`未返信メッセージ数: ${unreplied.length}`);
      
      if (unreplied.length === 0) {
        Logger.log("返信漏れはありません");
        return;
      }
  
      // 未返信メッセージの一覧を出力
      unreplied.forEach((m, i) => Logger.log(`${i + 1}. ${m.url}`));
      
      // 通知メッセージ作成(実際の送信はコメントアウト)
      const lines = unreplied.map((m, i) => `${i + 1}. ${m.url}`);
      const message = `未対応のメンションが見つかりました:\n${lines.join("\n")}`;
      
      postMessage(botToken, channelId, message);
      
    } catch (e) {
      Logger.log(`エラー: ${e.message}`);
      Logger.log(e.stack);
    }
  };
  
  // 親メッセージのURLをシンプルにする(thread_tsパラメータを除去)
  function getCleanParentUrl(url) {
    try {
      if (!url) return url;
      
      // クエリパラメータの位置を特定
      const queryIndex = url.indexOf('?');
      if (queryIndex === -1) return url;
      
      // 基本URLを取得(クエリパラメータの前まで)
      return url.substring(0, queryIndex);
    } catch (e) {
      Logger.log(`URL整形エラー: ${e.message}`);
      return url;
    }
  }
  
  // URLからthread_tsパラメータを抽出する関数
  function extractThreadTsFromUrl(url) {
    try {
      if (!url) return null;
      
      const match = url.match(/thread_ts=([0-9]+\.[0-9]+)/);
      return match ? match[1] : null;
    } catch (e) {
      Logger.log(`URL解析エラー: ${e.message}`);
      return null;
    }
  }
  
  // 自分へのメンションを検索
const searchMentions = async (token, myUserId) => {
  const today = new Date();
  const dayOfWeek = today.getDay(); // 0: 日, 1: 月, ..., 6: 土

  // 月曜日なら3日前(前週金曜日)の日付を取得
  const fromDate = new Date(today);
  if (dayOfWeek === 1) {
    fromDate.setDate(today.getDate() - 3);
  } else {
    fromDate.setDate(today.getDate() - 1);
  }

  const yyyy = fromDate.getFullYear();
  const mm = String(fromDate.getMonth() + 1).padStart(2, '0');
  const dd = String(fromDate.getDate()).padStart(2, '0');
  const dateString = `${yyyy}-${mm}-${dd}`;

  const query = `<@${myUserId}> after:${dateString}`;
  Logger.log(`検索クエリ: ${query}`);

  const response = await UrlFetchApp.fetch("https://slack.com/api/search.messages", {
    method: "post",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    payload: `query=${encodeURIComponent(query)}`,
  });

  const data = JSON.parse(response.getContentText());
  if (!data.ok) throw new Error("search.messages API 失敗: " + JSON.stringify(data));

  // 自分のメッセージは除外
  return (data.messages.matches || [])
    .filter(msg => msg.channel && msg.ts && msg.permalink && msg.user !== myUserId);
};
  
  // スレッドの全メッセージを取得
  const getThreadMessages = async (token, channelId, threadTs) => {
    try {
      const res = await UrlFetchApp.fetch(
        `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${threadTs}`, 
        {
          method: "get",
          headers: { Authorization: `Bearer ${token}` },
          muteHttpExceptions: true
        }
      );
      
      const data = JSON.parse(res.getContentText());
      if (!data.ok) {
        return [];
      }
      
      return data.messages || [];
    } catch (e) {
      return [];
    }
  };
  
  // Slackにメッセージを送信
  const postMessage = (token, channelId, text) => {
    UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", {
      method: "post",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      payload: JSON.stringify({
        channel: channelId,
        text: text,
        unfurl_links: false,  // リンクのプレビューを無効化
        unfurl_media: false   // メディアのプレビューを無効化
      }),
      muteHttpExceptions: true
    });
  };
  
  // スクリプトプロパティを取得
  const getProp = (key) => {
    const value = PropertiesService.getScriptProperties().getProperty(key);
    if (!value) throw new Error(`スクリプトプロパティ「${key}」が設定されていません`);
    return value;
  };
  1. スクリプトプロパティの設定

    • 画面左側のメニューから「プロジェクトの設定」をクリック
    • 「スクリプトプロパティ」タブを選択
    • 以下の4つのプロパティを追加:
      • SLACK_USER_TOKEN: 先ほど取得したUser OAuth Token
      • SLACK_BOT_TOKEN: 先ほど取得したBot User OAuth Token
      • SLACK_CHANNEL_ID: 通知を送信するSlackチャンネルのID
      • SLACK_MY_USER_ID: あなた自身のSlack ユーザーID
  2. ユーザーIDの取得方法

    • Slackでプロフィール画像をクリック
    • 「プロフィールを表示」→「︙」→「メンバーIDをコピー」
  3. チャンネルIDの取得方法

    • PCブラウザでSlackを開く
    • 対象のチャンネルを開く
    • URLの末尾部分がチャンネルID(例: https://app.slack.com/client/T012345/C0123456789C0123456789 部分)

3. スクリプトの実行と承認

  1. 初回実行

    • エディタ上部の「実行」ボタンの横の関数を「main」に設定
    • 「実行」ボタンをクリック
    • 権限承認ダイアログが表示されたら「許可」をクリック
  2. 実行ログの確認

    • 画面下部の「実行」タブでログを確認
    • 正常に動作していれば、メンション検索結果と未返信メッセージのリストが表示されます

4. トリガーの設定(定期実行)

  1. トリガー追加

    • メニューから「トリガー」→「トリガーを追加」をクリック
    • 以下の設定でトリガーを作成:
      • 実行する関数: main
      • イベントのソース: 時間主導型
      • 時間ベースのトリガーのタイプ: 日タイマー
      • 時刻: 任意の時間帯(例: 「午前9時〜10時」)
  2. トリガーを保存

    • 「保存」をクリックしてトリガーを有効化

トラブルシューティング

  1. 権限エラー

    • エラー: search.messages API 失敗
    • 解決: SlackアプリのOAuthスコープが正しく設定されているか確認
  2. 通知が送信されない

    • トークンが正しいか、チャンネルIDが正しいか確認
    • ボットがチャンネルに招待されているか確認

カスタマイズ

  • 検索期間の変更:

    • search.messages APIの検索クエリ (after:yesterday) を変更
    • 例: after:now-7d(過去7日間のメンション)
  • 通知頻度の変更:

    • トリガー設定で実行頻度を調整(日次、時間単位など)
  • 通知メッセージの形式変更:

    • postMessage 関数内のテキスト形式を変更

セキュリティ注意事項

  • Slackのトークンは外部に漏れないよう注意してください
  • スクリプトプロパティとして保存することで、コード内にトークンを直接記述せず安全に管理できます
  • 定期的にトークンのローテーション(再生成)を検討してください

まとめ

ここまでを実装することで自分宛のメンションで返信できていないものを抽出することができました。
例えば、スタンプで反応したものは出てきて欲しくないとか、CC的にメンションされたものは返信しにくくて毎回対象になって目障りだとかの改善ポイントはあるのですが、いったん最低限やりたいことはできているのでぜひご覧になった皆さんで機能を追加してみてもらえればと思います!
今回はClaude 3.7を使って半日ほどでツールを作成することができたので、今後はもっと活用して本業のクラウドエンジニアリングに活かせればなと思っています!

Discussion