😇

複数チャンネルにメッセージを一斉送信するGAS×Slackボットを作った話

に公開

この記事は NE Advent Calendar 2025 の3日目の記事です。

こんにちわ。
ロカルコ事業部でマネージャーをしている@yaginuuuです。

やりたいこと

弊社の自治体向けふるさと納税支援事業では、クライアントや返礼品事業者、パートナー企業ごとに Slack の専用チャンネルを用意し、報告相談等を実施しています。このようなケースは多いと思います。
本記事では、そうした複数チャンネルに対して「その都度考えた文面を、一斉送信するための仕組み」を Google スプレッドシートと Google Apps Script(GAS)、Slack Bot トークンで実装する方法を紹介します。

背景・課題

  • クライアント / 返礼品事業者 / パートナーのSlackチャンネルが数十個
  • 全社MTG、メンテ告知、障害連絡などで「同じメッセージを複数グループに送る」機会が多い
  • 毎回複数のチャンネルに手動コピペは非効率
  • 自動化は不要(文面はその都度考えるため)
  • 誤爆防止策必須

要件

✅ スプレッドシートで送信先(チャンネル)と種別を管理
✅ その都度、文面をシートに入力して送信
✅ カスタムメニューから「全件」「自治体だけ」「事業者だけ」「パートナーだけ」を選択
✅ 送信前に「プレビュー確認」+「最終確認ダイアログ」
✅ 1つのSlack Botトークンで複数チャンネルに送信

スプレッドシートの構成

channels シート(送信先マスタ)


A1: role_filter → 「自治体」or「返礼品事業者」or「パートナー」or空
B1: send_to_all → TRUE(全種別)/ FALSE(A1指定)
A3: 本文 → 「今週金曜は弊社側の全社MTGが...」

message シート(その都度の文面)

運用例:
A1: 自治体 B1: FALSE A3: [当日の案内文]
→ 自治体だけに送信

Slack Botトークンの準備

  1. SlackでApp作成 → Bot User追加
  2. スコープ:chat:write
  3. 各チャンネルにBot招待
  4. トークン(xoxb-...)をGAS「スクリプトプロパティ」に保存
    キー:SLACK_BOT_TOKEN

実装コード

  1. カスタムメニュー
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('Slack通知')
    .addItem('送信対象をプレビュー', 'previewTargets')
    .addSeparator()
    .addItem('全件送信', 'sendAll')
    .addSeparator()
    .addItem('自治体向けを送信', 'sendToJichitai')
    .addItem('返礼品事業者向けを送信', 'sendToVendor')
    .addItem('パートナー向けを送信', 'sendToPartner')
    .addToUi();
}
  1. エントリーポイント
function sendAll() {
  const message = getMessageBody_();
  if (!message) return;
  const rows = getChannelsRows_();
  notifyToSlack_(rows, message);
}

function sendToJichitai() { sendByRole_('自治体'); }
function sendToVendor() { sendByRole_('返礼品事業者'); }
function sendToPartner() { sendByRole_('パートナー'); }

function sendByRole_(roleType) {
  const message = getMessageBody_();
  if (!message) return;
  const rows = getChannelsRows_(roleType);
  notifyToSlack_(rows, message);
}
  1. messageシート読み取り
function getMessageBody_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('message');
  if (!sheet) {
    SpreadsheetApp.getUi().alert('message シートが見つかりません。');
    return '';
  }
  const body = sheet.getRange('A3').getValue();
  if (!body) {
    SpreadsheetApp.getUi().alert('message シートの A3 に本文を入力してください。');
    return '';
  }
  return body;
}
  1. channelsシート読み取り
function getChannelsRows_(roleType) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('channels');
  if (!sheet) {
    SpreadsheetApp.getUi().alert('channels シートが見つかりません。');
    return [];
  }
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return [];
  const values = sheet.getRange(2, 1, lastRow - 1, 6).getValues();
  
  return values
    .filter(row => row[5] && (!roleType || row[0] === roleType))
    .map(row => ({
      roleType: row[0], name: row[1], channelId: row[2],
      mentionType: row[3], mentionTarget: row[4]
    }));
}
  1. プレビュー機能
function previewTargets() {
  const ui = SpreadsheetApp.getUi();
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const msgSheet = ss.getSheetByName('message');
  if (!msgSheet) { ui.alert('message シートが見つかりません。'); return; }
  
  const roleFilter = msgSheet.getRange('A1').getValue();
  const sendToAll = msgSheet.getRange('B1').getValue();
  let rows = sendToAll ? getChannelsRows_() : (roleFilter ? getChannelsRows_(roleFilter) : []);
  
  if (!rows.length) {
    ui.alert('現在の設定では送信対象のチャンネルがありません。');
    return;
  }
  
  const lines = rows.map((row, i) => `${i+1}. [${row.roleType}] ${row.name} (${row.channelId})`);
  ui.alert('送信対象チャンネル(' + rows.length + '件):\n\n' + lines.join('\n'));
}
  1. 送信+確認ダイアログ
function notifyToSlack_(rows, message) {
  const ui = SpreadsheetApp.getUi();
  if (!rows.length) { ui.alert('送信対象のチャンネルがありません。'); return; }
  
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN');
  if (!token) { ui.alert('スクリプトプロパティに SLACK_BOT_TOKEN が設定されていません。'); return; }
  
  const previewLines = rows.slice(0, 10).map((row, i) => `${i+1}. [${row.roleType}] ${row.name} (${row.channelId})`);
  let confirmText = `以下の ${rows.length}件に送信:\n\n${previewLines.join('\n')}`;
  if (rows.length > 10) confirmText += `\n…ほか ${rows.length-10}件`;
  confirmText += '\n\nよろしいですか?';
  
  if (ui.alert('Slack送信の確認', confirmText, ui.ButtonSet.OK_CANCEL) !== ui.Button.OK) {
    ui.alert('送信をキャンセルしました。');
    return;
  }
  
  let success = 0, fail = 0;
  rows.forEach(row => {
    const mention = buildMentionText_(row.mentionType, row.mentionTarget);
    const text = mention ? `${mention}\n${message}` : message;
    if (postToSlack_(token, row.channelId, text)) success++; else fail++;
  });
  
  ui.alert(`送信完了: ${success}件 / 失敗: ${fail}件`);
}
  1. メンション生成+Slack API
function buildMentionText_(mentionType, mentionTarget) {
  if (!mentionType) return '';
  if (mentionType === 'here') return '<!here>';
  if (mentionType === 'channel') return '<!channel>';
  if (mentionType === 'user' && mentionTarget) {
    return String(mentionTarget).split(',').map(s => s.trim()).filter(s => s)
      .map(id => `<@${id}>`).join(' ');
  }
  return '';
}

function postToSlack_(token, channelId, text) {
  const url = 'https://slack.com/api/chat.postMessage';
  const payload = { channel: channelId, text };
  const params = {
    method: 'post',
    contentType: 'application/json; charset=utf-8',
    headers: { Authorization: 'Bearer ' + token },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  
  const res = UrlFetchApp.fetch(url, params);
  if (res.getResponseCode() !== 200) {
    console.error('Slack API error: ' + res.getContentText());
    return false;
  }
  return JSON.parse(res.getContentText()).ok;
}

運用フロー

  1. messageシート編集
    A1: 「自治体」 B1: FALSE A3: [案内文]

  2. メニュー「Slack通知 → 送信対象をプレビュー」
    → 「1. [自治体] ○○市役所...」一覧確認

  3. 「Slack通知 → 自治体向けを送信」
    → 最終確認ダイアログ → OK → 一斉送信完了!

運用してみて

✅ 手動コピペ数十回 → メニュー1クリックに短縮
✅ プレビュー+確認で誤爆ゼロ
✅ 文面は都度自由編集(テンプレ化不要)
✅ enabled列で一時停止も簡単
✅ GAS+スプレッドシートでゼロコスト

サクッと作れて、みんなハッピーなので、やってみてください。

Discussion