commit-report-tool/.claude/skills/slack-formatting/scripts/post-report.js
2026-04-03 19:31:32 +09:00

261 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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
/**
* Slack レポート投稿スクリプト
*
* 複数の画像を1メッセージにまとめてSlackチャンネルに投稿します。
* ※スレッドではなく、チャンネルに直接投稿します
*
* Required Environment Variables:
* SLACK_BOT_TOKEN - Bot User OAuth Token (xoxb-...)
* SLACK_CHANNEL - Channel ID (C...)
*
* Usage:
* node post-report.js --message "テキスト" <image1.png> <image2.png> ...
*
* Example:
* node post-report.js --message "📊 今日のレポート" \
* /tmp/daily-summary.png /tmp/by-app.png /tmp/timeline.png
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
// 環境変数SLACK_CHANNEL または SLACK_CHANNEL_ID をサポート)
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const CHANNEL_ID = process.env.SLACK_CHANNEL || process.env.SLACK_CHANNEL_ID;
const DEBUG_CHANNEL = 'YOUR_DEBUG_CHANNEL_ID';
// ========================================
// ログ出力(原因特定用)
// ========================================
function log(message) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
}
function logSection(title) {
console.log('');
console.log('========================================');
console.log(`=== ${title} ===`);
console.log('========================================');
}
// ========================================
// HTTPS リクエスト
// ========================================
function httpsRequest(options, postData) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
resolve({ ok: false, error: 'parse_error', raw: data });
}
});
});
req.on('error', reject);
if (postData) req.write(postData, 'utf8');
req.end();
});
}
// ========================================
// Slack API 関数
// ========================================
async function getUploadUrl(filename, fileSize) {
const params = new URLSearchParams({
filename,
length: fileSize.toString()
});
return await httpsRequest({
hostname: 'slack.com',
path: '/api/files.getUploadURLExternal',
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_TOKEN}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}, params.toString());
}
async function uploadFile(uploadUrl, filePath) {
const fileContent = fs.readFileSync(filePath);
const filename = path.basename(filePath);
const boundary = '----FormBoundary' + Math.random().toString(36).substring(2);
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([
Buffer.from(header, 'utf-8'),
fileContent,
Buffer.from(footer, 'utf-8')
]);
const url = new URL(uploadUrl);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length
}
}, (res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function completeUpload(fileIds, message, channelId) {
const files = fileIds.map(id => ({ id }));
const body = JSON.stringify({
files,
channel_id: channelId,
initial_comment: message || ''
});
return await httpsRequest({
hostname: 'slack.com',
path: '/api/files.completeUploadExternal',
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_TOKEN}`,
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
}
}, body);
}
// ========================================
// メイン処理
// ========================================
async function postReport(imagePaths, message) {
const channelId = CHANNEL_ID || DEBUG_CHANNEL;
logSection('SLACK投稿開始');
log(`投稿先チャンネル: ${channelId}`);
log(`画像数: ${imagePaths.length}`);
log(`メッセージ: ${message.length > 50 ? message.substring(0, 50) + '...' : message}`);
// 画像一覧を表示
imagePaths.forEach((p, i) => {
log(` [${i + 1}] ${path.basename(p)}`);
});
const fileIds = [];
// 各画像をアップロード
for (let i = 0; i < imagePaths.length; i++) {
const imagePath = imagePaths[i];
const filename = path.basename(imagePath);
const fileSize = fs.statSync(imagePath).size;
log(`アップロード中: ${filename} (${Math.round(fileSize / 1024)}KB)`);
// Step 1: Get upload URL
const urlResponse = await getUploadUrl(filename, fileSize);
if (!urlResponse.ok) {
log(` エラー: ${urlResponse.error}`);
continue;
}
// Step 2: Upload file
await uploadFile(urlResponse.upload_url, imagePath);
log(` 完了: ${urlResponse.file_id}`);
fileIds.push(urlResponse.file_id);
}
if (fileIds.length === 0) {
logSection('SLACK投稿エラー');
log('アップロードされたファイルがありません');
return { ok: false, error: 'no_files_uploaded' };
}
// Step 3: Complete upload (posts all files at once to channel, NOT thread)
log('');
log('チャンネルに投稿中...');
const result = await completeUpload(fileIds, message, channelId);
if (result.ok) {
logSection('SLACK投稿完了');
log(`チャンネル: ${channelId}`);
log(`画像数: ${fileIds.length}`);
log('✅ 投稿成功!');
} else {
logSection('SLACK投稿エラー');
log(`エラー: ${result.error}`);
if (result.raw) log(`詳細: ${result.raw}`);
}
return result;
}
async function main() {
// 環境変数チェック
if (!SLACK_TOKEN) {
console.error('エラー: SLACK_BOT_TOKEN 環境変数が必要です');
process.exit(1);
}
if (!CHANNEL_ID) {
log(`警告: SLACK_CHANNEL が未設定のため、デバッグチャンネル (${DEBUG_CHANNEL}) に投稿します`);
}
// 引数パース
const args = process.argv.slice(2);
let message = '';
const imagePaths = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--message' || args[i] === '-m') {
message = args[++i] || '';
} else if (args[i].startsWith('-')) {
console.error(`不明なオプション: ${args[i]}`);
process.exit(1);
} else {
imagePaths.push(args[i]);
}
}
// 使い方表示
if (imagePaths.length === 0) {
console.error('使い方: node post-report.js --message "テキスト" <image1.png> ...');
console.error('');
console.error('必須環境変数:');
console.error(' SLACK_BOT_TOKEN - Bot OAuth Token');
console.error(' SLACK_CHANNEL - チャンネルID');
process.exit(1);
}
// ファイル存在確認
const missingFiles = imagePaths.filter(p => !fs.existsSync(p));
if (missingFiles.length > 0) {
console.error('ファイルが見つかりません:');
missingFiles.forEach(f => console.error(` - ${f}`));
process.exit(1);
}
// 投稿実行
const result = await postReport(imagePaths, message);
process.exit(result.ok ? 0 : 1);
}
main();