progress-dashboard/scripts/post-to-slack.js
2026-04-03 19:28:30 +09:00

214 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Post dashboard screenshot to Slack
*
* Uploads dashboard screenshot to Slack with summary message
*
* Dry run: DRY_RUN=1, DRY_RUN=true, or --dry-run
* - Does not post. Prints payload summary.
* - If SLACK_BOT_TOKEN is set, calls auth.test only (no channel post).
*/
import 'dotenv/config';
import { WebClient } from '@slack/web-api';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function isDryRun() {
return (
process.env.DRY_RUN === '1' ||
process.env.DRY_RUN === 'true' ||
process.argv.includes('--dry-run')
);
}
/**
* Build Slack post payload from local files (no network).
*/
async function buildPostPayload() {
const dataPath = path.join(__dirname, '..', 'data', 'health-data.json');
const healthData = JSON.parse(await fs.readFile(dataPath, 'utf-8'));
const now = new Date(healthData.calculatedAt).toLocaleString('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
let dashboardUrl = null;
try {
const urlPath = path.join(__dirname, '..', 'data', 'deployed-url.txt');
dashboardUrl = (await fs.readFile(urlPath, 'utf-8')).trim();
} catch {
// skip
}
let messageText = `📊 マガジン進捗管理ダッシュボード(${now}\n\n`;
if (dashboardUrl) {
messageText += `🔗 <${dashboardUrl}|ダッシュボードを開く>\n\n`;
}
messageText += `*全体健康度*: ${healthData.overallHealth.status} ${healthData.overallHealth.label}`;
const screenshotPaths = [
path.join(__dirname, '..', 'output', 'screenshot-1.png'),
path.join(__dirname, '..', 'output', 'screenshot-2.png'),
path.join(__dirname, '..', 'output', 'screenshot-3.png')
];
const fileUploads = [];
for (let i = 0; i < screenshotPaths.length; i++) {
try {
await fs.access(screenshotPaths[i]);
const imageBuffer = await fs.readFile(screenshotPaths[i]);
fileUploads.push({
file: imageBuffer,
filename: `magazine-dashboard-${i + 1}.png`,
path: screenshotPaths[i],
sizeBytes: imageBuffer.length
});
} catch {
// missing
}
}
return { messageText, dashboardUrl, fileUploads, healthData };
}
async function postToSlack() {
const dryRun = isDryRun();
if (dryRun) {
console.log('🧪 DRY RUN — Slack には投稿しません\n');
} else {
console.log('💬 Slackに投稿中...');
}
const channelId = process.env.MAGAZINE_SLACK_CHANNEL_ID;
if (!channelId) {
console.error('❌ MAGAZINE_SLACK_CHANNEL_ID が設定されていません');
console.log('💡 .env ファイルに MAGAZINE_SLACK_CHANNEL_ID=C0XXXXXXXXX を設定してください');
process.exit(1);
}
if (!dryRun && !process.env.MAGAZINE_SLACK_BOT_TOKEN) {
console.error('❌ MAGAZINE_SLACK_BOT_TOKEN が設定されていません');
console.log('💡 .env ファイルに MAGAZINE_SLACK_BOT_TOKEN=xoxb-xxxxx を設定してください');
process.exit(1);
}
console.log(`📮 投稿先チャンネル ID: ${channelId}`);
try {
const { messageText, dashboardUrl, fileUploads } = await buildPostPayload();
if (dryRun) {
console.log('--- 投稿予定の本文 ---');
console.log(messageText.replace(/<([^|>]+)\|([^>]+)>/g, '$2 ($1)'));
console.log('---');
console.log(`ダッシュボードURL: ${dashboardUrl ?? '(なし)'}`);
console.log(`添付画像: ${fileUploads.length}`);
for (const u of fileUploads) {
console.log(` - ${u.filename} (${u.sizeBytes} bytes)`);
}
if (fileUploads.length === 0) {
console.log(' (スクリーンショットなし → テキストのみ投稿の想定)');
}
if (process.env.MAGAZINE_SLACK_BOT_TOKEN) {
const slack = new WebClient(process.env.MAGAZINE_SLACK_BOT_TOKEN);
const auth = await slack.auth.test();
if (auth.ok) {
console.log('\n✅ auth.test OKトークンは有効');
console.log(` bot: ${auth.user ?? auth.bot_id ?? '—'} / team: ${auth.team ?? '—'}`);
} else {
console.log('\n⚠ auth.test 失敗:', auth.error);
process.exit(1);
}
} else {
console.log('\n💡 MAGAZINE_SLACK_BOT_TOKEN 未設定のため auth.test はスキップ');
}
console.log('\n✅ ドライラン完了');
return;
}
const slack = new WebClient(process.env.MAGAZINE_SLACK_BOT_TOKEN);
const uploadsForApi = fileUploads.map(({ file, filename }) => ({ file, filename }));
console.log(`📤 メッセージを投稿中... (画像: ${uploadsForApi.length}枚)`);
if (uploadsForApi.length > 0) {
const uploadResult = await slack.files.uploadV2({
channel_id: channelId,
file_uploads: uploadsForApi,
initial_comment: messageText
});
if (uploadResult.ok) {
console.log('✅ 画像アップロード完了');
console.log('✅ Slackへの投稿が完了しました!');
console.log(`📍 投稿先チャンネルID: ${channelId}`);
if (uploadResult.files && uploadResult.files.length > 0) {
const file = uploadResult.files[0];
if (file.files && file.files.length > 0 && file.files[0].permalink) {
console.log(`📎 ファイルURL: ${file.files[0].permalink}`);
}
}
return;
}
console.error('⚠️ アップロードエラー:', uploadResult.error);
process.exit(1);
}
console.log('⚠️ スクリーンショットが見つからないため、テキストのみ投稿します');
const postResult = await slack.chat.postMessage({
channel: channelId,
text: messageText
});
if (postResult.ok) {
console.log('✅ Slackへの投稿が完了しました! (テキストのみ)');
console.log(`📍 投稿先チャンネルID: ${channelId}`);
return;
}
console.error('⚠️ 投稿エラー:', postResult.error);
process.exit(1);
} catch (error) {
console.error('❌ エラーが発生しました:', error.message);
if (error.data?.error === 'not_in_channel') {
console.log('💡 ボットをチャンネルに追加してください:');
console.log(` /invite @your-bot-name をチャンネルで実行`);
} else if (error.data?.error === 'channel_not_found') {
console.log('💡 チャンネルが見つかりません。チャンネルIDを確認してください。');
console.log(' プライベートチャンネルの場合は、ボットを招待する必要があります。');
} else if (error.data?.error === 'invalid_auth') {
console.log('💡 Slack Bot Token が無効です。.env ファイルを確認してください。');
}
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
postToSlack();
}
export default postToSlack;