Slackbot と Google Apps Script で、商談ダッシュボードの作成と「固定URL化」を自動化した話

QUARTET communications では、ユニット長会議で商談の状況を確認するため、Salesforce のデータからダッシュボードを作っています。この記事では、Slackbot のスキルでダッシュボードを作り、**Google Apps Script(GAS)**で固定の URL から最新版を見られるようにした仕組みと、作るときに気をつけた点を紹介します。後半には、まねして作れるように設定手順とコードも載せています。


SlackbotでコーディングしてHTML形式で作成したダッシュボード
※SlackにはSlackサーフェスというBIツールのようなものがありますが、今回のケースでは取り扱うデータ量が多くサーフェスの作成が失敗してしまったため今回の方法論になっています
1. ここに至るまで
Salesforce のレポートだけでは足りなかった
商談のデータは Salesforce で管理しています。ただ、Salesforce のレポートやダッシュボードだけでは、業績の報告に必要な見せ方を表現しきれませんでした。たとえば、予算に対する進捗率、前年同期との比較、事業別とフェーズ別を掛け合わせた集計、要注意の商談のアラートなどです。
そのため、業績関連の報告資料は、Salesforce からデータを出してスプレッドシートで作っていました。毎回の手作業が多く、作る人の負担も大きい状態でした。
BI ツールは、実装と運用のコストで断念
Tableau などの BI ツールの導入も検討しました。ただ、導入時の実装に加えて、ライセンス費用や、ダッシュボードを作り直せる人を社内で育てる運用面のコストが高く、見送りました。
生成 AI で、ダッシュボードを作れるようになった
状況が変わったのは、生成 AI が業務で使えるようになってからです。AI を MCP(AI と外部のサービスをつなぐ仕組み)で Salesforce につなぐと、AI が商談データを直接取得できます。そこから、予実や前年比、アラートまで入ったダッシュボードを、1つの HTML ファイルとして作れるようになりました。グラフ、フィルタ、集計はすべてブラウザの中で動くので、サーバーも要りません。
次に見えてきた課題
ダッシュボードの中身には満足できるようになりましたが、作り方と配り方に課題が残りました。
- 更新できるのが1人だけ:AI に手順を渡して HTML を作っていたので、その AI を使っている人しか更新できない
- ファイルを配る手間:毎週 HTML ファイルを共有し、各自がブラウザで開く。どれが最新か分かりにくい
- AI を使っていないメンバーもいる:見る側も作る側も、特定のツールに頼らない形にしたい
目指したのは、**「Web サイトのように、決まった URL を開けば最新のダッシュボードが見られる」**状態です。
2. 全体の仕組み

① メンバーが Slackbot に「今週分作って」と頼む(または定期実行)
↓
② Slackbot がスキルに従って Salesforce からデータを取得し、
雛形の HTML にデータを入れてダッシュボードを作る
↓
③ 作った HTML を Slack の専用チャンネルに投稿する
↓
④ GAS の Web アプリが、チャンネルの最新 HTML を取得して表示する
↓
⑤ メンバーは固定の URL を開くだけ(社内のアカウントだけが見られる)
使ったのは、Slack の標準のAIアシスタント(Slackbot)と、Google Workspace の標準機能(GAS)だけです。新しい有料ツールは増やしていません。
3. ポイント1:「雛形」と「データ」を分ける

一番大事にしたのは、毎回 AI に HTML を書かせないことです。
ダッシュボードの HTML は、デザインや集計ロジックを含めると数万字あります。毎回 AI にゼロから書かせると、週ごとに見た目や集計方法が変わってしまいます。そこで、HTML を次の2つに分けました。
- 雛形:デザインと集計ロジックだけが入った HTML。データの入る場所に
__DATA__、取得日の入る場所に__TODAY__という目印を置いておく - データ:Salesforce から取得した商談のデータを、決まった形の JSON にしたもの
毎週の作業は、雛形の目印を JSON に置き換えるだけです。AI に任せるのはデータの取得と整形で、HTML の中身には手を触れさせません。
置き換えのときに1つ気をつけた点があります。Python の % 書式や .format() を使うと、CSS の中の % や {} が書式の記号として読み取られ、HTML が壊れてしまいます。そのため、スキルには「置き換えは str.replace() で行う」と明記しました。
4. ポイント2:手順を Slackbot の「スキル」にする
Slackbot には、決まった手順を登録しておける「スキル」という機能があります。今回は、ダッシュボードを作る手順をまるごとスキルにしました。
スキルに書いたのは、次の内容です。
| 項目 | 内容 |
|---|---|
| 絶対に守ること | HTML をゼロから書かない/データを要約・間引きしない/置き換えは str.replace() で行う |
| ステップ0:依頼の判断 | 依頼が「作成」「フォーマットの修正」「質問」のどれかを判断してから動く。依頼の文がなければ、何もせずに聞き返す |
| ステップ1:雛形を読み込む | スキルに添付した雛形を読み込み、目印の数を確認する |
| ステップ2:データ取得 | Salesforce から取得する条件と項目(SOQL) |
| ステップ3:整形と置き換え | JSON の形と、値がないときの扱い |
| ステップ4:検算 | 件数と金額の合計を、レポート画面の値と比べる。減っていたら取得漏れを疑い、HTML を出さずに報告する |
| ステップ5:フォーマットの修正 | 頼まれた箇所だけを直す。影響が出そうな点は報告する。修正後の雛形も出す |
| 出力 | HTML と検算結果を、専用チャンネルに投稿する |
**雛形の HTML はスキルにファイルとして添付しました。**スキルの編集画面はキャンバスと同じ作りになっていて、クリップのアイコンからファイルを添付できます。これで、手順と雛形の置き場所が1か所にまとまりました。

「ステップ0」を入れた理由
最初は、スキルを呼んだだけで、Slackbot がいきなりダッシュボードを作り始めてしまいました。手順が一本道だったためです。そこで、最初に依頼の中身を判断する「ステップ0」を入れました。今は、雛形について質問しただけならデータを取得せずに答え、「今週分作って」と頼んだときだけダッシュボードを作ります。

検算を手順に組み込む
AI にデータを扱わせるときは、結果が正しいかを AI 自身に確かめさせる仕組みが欠かせません。スキルには、件数と金額の合計を Salesforce のレポート画面の値と比べる「検算」のステップを入れました。参考値はレポート画面を見て書き、取得結果から書き写さないようにしています。
5. ポイント3:フォーマットの修正も Slackbot に任せられるか試した
ダッシュボードは、今後も項目を足したり見せ方を変えたりしていく予定です。そこで、Slackbot がフォーマットの修正を意図どおりにできるかを、難しさの違う3つの修正で試しました。
| 難しさ | 頼んだ修正 | 結果 |
|---|---|---|
| 小 | KPI カードの並び順を変える | 該当する1行だけを動かした |
| 中 | アラートの指標を1つ追加する(件数カード・担当者別の表・一覧のバッジにも反映) | アラートの定義をまとめた配列に1行足すだけで、3か所に自動で反映させた |
| 大 | 「営業担当 × 事業」の受注金額ヒートマップを追加する(フィルタに連動) | 正しく動き、横スクロールが出るなどの影響も自分から説明した |
どの修正でも、修正前後の HTML の差分を取り、頼んだ箇所以外が1文字も変わっていないことを確かめました。大きい修正では実際に HTML を動かし、集計値やフィルタの連動も確認しています。
特に良かったのは、既存のコードの仕組みを読んだうえで、最小限の変更で済ませている点です。この結果を見て、フォーマットの修正も Slackbot に任せると決めました。
6. ポイント4:GAS で固定 URL にする
最後に、Slack に投稿された HTML を、決まった URL で見られるようにしました。
仕組み
GAS の Web アプリで、URL が開かれるたびに次の処理をします。
- Slack の API で専用チャンネルのファイル一覧を取得する
- ファイル名の規則(
YYYYMMDD_〜-dashboard.html)に合うもののうち、一番新しく投稿されたものを選ぶ - その HTML をダウンロードして、そのまま表示する
画面の右下には「いつ投稿された版か」を表示し、過去版の一覧にも移れるようにしました。

安全面で気をつけたこと
- Slack アプリの権限は、ファイルの読み取り(
files:read)だけ:メッセージの投稿や削除はできません。読み取れるのも、アプリを招待したチャンネルだけです - チャンネルはプライベート:そのチャンネルに HTML を投稿できる人は表示内容を変えられるので、メンバーを絞っています
- 見られるのは社内のアカウントだけ:GAS の公開範囲を、自社のドメインに限定しています
- トークンはコードに書かない:Slack のトークンは、GAS の「スクリプトプロパティ」に保存しています
最初は Google ドライブ経由にしていた
最初は、HTML を Google ドライブのフォルダに置き、GAS がそのフォルダの最新ファイルを表示する仕組みでした。ところが、Slackbot はドライブを読めても書き込めませんでした。そこで、GAS が Slack から直接取りに行く形に切り替えました。どちらの形でも、閲覧用の URL は変わりません。
7. 運用してみて
今は、次の流れで回っています。
- 毎週火曜の朝、Slackbot のタスク(定期実行)がダッシュボードを作る
- 専用チャンネルに、HTML と検算結果が投稿される
- メンバーは、いつもの URL を開くだけ
ダッシュボードの更新に、特定の人の PC や AI ツールは要らなくなりました。フォーマットを変えたいときも、Slack で Slackbot に頼めば済みます。
つまずいたところ
- キャンバスの書式:スキルに
__DATA__と書くと、前後のアンダースコアが太字や斜体の記号として読み取られ、文字が消えてしまいました。インラインコードの書式にして解決しました - 実行する人の権限:Slackbot は、頼んだ人の Salesforce の権限でデータを取得します。権限の狭い人が頼むと件数が減るので、検算で止まるようにしています。定期実行は、全件を見られる人のアカウントで設定しました
- 雛形の置き場所:雛形を直したら、スキルに添付した雛形も差し替える必要があります。手順書にも、その旨を書いておきました
8. これから
今後は、案件や売上のダッシュボードも同じ仕組みで作る予定です。GAS と Slack アプリは1つのまま、URL のパラメータ(?db=sales など)でダッシュボードを切り替え、画面上部のタブから行き来できるようにするつもりです。GAS は URL を開いた人のメールアドレスを取得できるので、「このタブはユニット長だけ」といった出し分けもできます。
付録A:Slackbot スキルのひな形
自社のデータに合わせて、<> の部分を書き換えて使ってください。
タイトル:<ダッシュボード名>作成・更新
説明:
<ダッシュボード名>(HTML)を、Salesforce の最新データで作成する。
また、ダッシュボードのフォーマット(雛形)を修正する。
「ダッシュボード作って」「今週分作って」「ダッシュボードの〇〇を直して」と頼まれたときに使う。
アプリ:Salesforce
■ 絶対に守ること
・HTML をゼロから書かない。必ずこのスキルに添付した雛形を使う
・データを要約・間引きしない。取得した全件を入れる
・雛形の置き換えは Python の str.replace() で行う。% 書式・.format()・f文字列に雛形を通さない
■ ステップ 0:依頼の内容を判断する(最初に必ず行う)
・作成・更新(「作って」「今週分」など)→ ステップ 1〜4
・フォーマットの修正(「〇〇を直して」など)→ ステップ 1 と 5
・それ以外(質問や確認)→ 頼まれたことだけを行う。データの取得はしない
・依頼の文がない、またはどれか分からないときは、何もせずに聞き返す
■ ステップ 1:雛形を読み込む
・このスキルに添付した雛形を、一字一句そのまま読み込む
・__DATA__ が1か所、__TODAY__ が<n>か所あることを確認する。違ったら止めて報告する
■ ステップ 2:Salesforce からデータを取得する(全件、件数の上限なし)
SELECT Id, Name, StageName, CloseDate, CreatedDate, Owner.Name, Account.Name, Amount
FROM Opportunity
WHERE <条件>
■ ステップ 3:データを整形して雛形に入れる
・<JSON の形と、各キーの意味、値がないときの扱い>
・JSON の中の </ は <\/ にエスケープしてから __DATA__ と置き換える
・__TODAY__ を実行日(日本時間)の YYYY-MM-DD に置き換える
・ファイル名は YYYYMMDD_<名前>-dashboard.html
■ ステップ 4:検算する
・置き換え後に __DATA__ と __TODAY__ が残っていないこと
・件数と金額の合計を、参考値(レポート画面で確認した値)と比べる
・参考値より大きく減っていたら、取得漏れを疑い、HTML を出さずに報告する
■ ステップ 5:フォーマットの修正を頼まれたとき
・頼まれた箇所だけを修正する。それ以外は1文字も変えない
・既存の仕組みがあれば、それに乗せる最小限の変更にする
・既存の機能に影響が出そうな点があれば報告する(修正はしない)
・データ入りの HTML と、修正後の雛形の両方を出す
■ 出力
・データ入りの HTML ファイルと検算結果
・作成したダッシュボードは、検算結果と一緒に #<チャンネル名> に投稿する
(フォーマットの修正だけのときや、検算で異常があったときは投稿しない)
付録B:Slack アプリと GAS の設定手順
Slack アプリ
- https://api.slack.com/apps で「Create New App」→「From scratch」
- 「OAuth & Permissions」の「Bot Token Scopes」に
files:readを追加 - ワークスペースにインストールし、Bot User OAuth Token(
xoxb-…)を控える (Enterprise の環境では、管理者の承認が必要なことがあります) - 専用チャンネルで
/invite @アプリ名を実行し、アプリを招待する - チャンネル名をクリックし、一番下の「チャンネル ID」を控える
GAS
- https://script.google.com で新しいプロジェクトを作り、付録Cのコードを貼り付ける
- 「プロジェクトの設定」→「スクリプト プロパティ」に
SLACK_TOKENとCHANNEL_IDを登録する testSlackを実行し、ログにファイル名と HTML の先頭が出ることを確認する- 「デプロイ」→「新しいデプロイ」→ 種類は「ウェブアプリ」
- 次のユーザーとして実行:自分
- アクセスできるユーザー:自社ドメイン内の全員
- 発行された URL をメンバーに共有する
コードを直したあとは、「新しいデプロイ」ではなく「デプロイを管理」から新しいバージョンにします。こうすると URL が変わりません。
付録C:GAS のコード
/**
* ダッシュボード ビューア(Google Apps Script Webアプリ)
* Slack の専用チャンネルに投稿された最新の HTML を表示する。
*
* URLパラメータ
* (なし) … 最新版を表示
* ?list=1 … 過去版の一覧を表示
* ?id=ファイルID … 指定した版を表示
*
* スクリプトプロパティ
* SLACK_TOKEN : Bot User OAuth Token(xoxb-…)
* CHANNEL_ID : 専用チャンネルの ID
* TEAM_ID : (Enterprise Grid で必要な場合のみ)ワークスペース ID
*/
const PAGE_TITLE = 'ダッシュボード';
const FILE_PATTERN = /^\d{8}_.*-dashboard.*\.html$/;
function doGet(e) {
const params = (e && e.parameter) || {};
let files;
try {
files = listDashboardFiles_();
} catch (err) {
return simplePage_('Slackからファイル一覧を取得できませんでした:' + err.message);
}
if (files.length === 0) return simplePage_('チャンネルにダッシュボードのファイルが見つかりません。');
if (params.list) return listPage_(files);
let target = files[0];
if (params.id) {
target = files.find(f => f.id === params.id);
if (!target) return simplePage_('指定された版が見つかりません。');
}
let html;
try {
html = downloadFile_(target);
} catch (err) {
return simplePage_('ファイルを取得できませんでした:' + err.message);
}
html = injectBadge_(html, target, target === files[0]);
return HtmlService.createHtmlOutput(html)
.setTitle(PAGE_TITLE)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function listDashboardFiles_() {
const p = PropertiesService.getScriptProperties();
const q = { channel: p.getProperty('CHANNEL_ID'), types: 'all', count: 100 };
const teamId = p.getProperty('TEAM_ID');
if (teamId) q.team_id = teamId;
const res = slackApi_('files.list', q);
return (res.files || [])
.filter(f => FILE_PATTERN.test(f.name || ''))
.map(f => ({ id: f.id, name: f.name, created: f.created, url: f.url_private_download || f.url_private }))
.sort((a, b) => b.created - a.created);
}
function downloadFile_(f) {
const token = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
const res = UrlFetchApp.fetch(f.url, { headers: { Authorization: 'Bearer ' + token }, muteHttpExceptions: true });
if (res.getResponseCode() !== 200) throw new Error('HTTP ' + res.getResponseCode());
const text = res.getContentText('UTF-8');
// 権限がないと Slack のログイン画面が返ってくるため、中身を確認する
if (!/^\s*<!doctype html/i.test(text)) throw new Error('HTMLを取得できませんでした(権限を確認してください)');
return text;
}
function slackApi_(method, params) {
const token = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
const qs = Object.keys(params).map(k => k + '=' + encodeURIComponent(params[k])).join('&');
const res = UrlFetchApp.fetch('https://slack.com/api/' + method + '?' + qs, {
headers: { Authorization: 'Bearer ' + token },
muteHttpExceptions: true,
});
const json = JSON.parse(res.getContentText());
if (!json.ok) throw new Error(json.error);
return json;
}
function injectBadge_(html, target, isLatest) {
const base = ScriptApp.getService().getUrl();
const label = `${fmtTime_(target.created)} 投稿${isLatest ? '(最新)' : ''}`;
const badge =
`<div style="position:fixed;right:12px;bottom:12px;z-index:99999;background:rgba(0,0,0,.65);` +
`color:#fff;font:12px sans-serif;padding:6px 10px;border-radius:6px;">${esc_(label)} ・ ` +
`<a href="${base}?list=1" target="_top" style="color:#9cf;">過去版</a></div>`;
return html.includes('</body>') ? html.replace('</body>', badge + '</body>') : html + badge;
}
function listPage_(files) {
const base = ScriptApp.getService().getUrl();
const rows = files.map((f, i) =>
`<li><a href="${base}?id=${encodeURIComponent(f.id)}" target="_top">${fmtTime_(f.created)} 投稿</a>` +
`${i === 0 ? ' <b>(最新)</b>' : ''} <span style="color:#888">${esc_(f.name)}</span></li>`
).join('');
return simplePage_(`<h2>${esc_(PAGE_TITLE)} 過去版一覧</h2><p><a href="${base}" target="_top">→ 最新版を開く</a></p><ul>${rows}</ul>`, true);
}
function simplePage_(content, isHtml) {
const body = isHtml ? content : `<p>${esc_(content)}</p>`;
return HtmlService.createHtmlOutput(
`<div style="font-family:sans-serif;max-width:720px;margin:24px auto;padding:0 16px;line-height:1.8">${body}</div>`
).setTitle(PAGE_TITLE).addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function fmtTime_(sec) {
return Utilities.formatDate(new Date(sec * 1000), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm');
}
function esc_(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
/** 動作確認用:エディタで実行すると、見つかったファイルがログに出る */
function testSlack() {
const files = listDashboardFiles_();
Logger.log('件数: ' + files.length);
files.slice(0, 5).forEach(f => Logger.log(fmtTime_(f.created) + ' ' + f.name + ' ' + f.id));
if (files.length) Logger.log('最新の中身の先頭: ' + downloadFile_(files[0]).slice(0, 60));
}
