feat: コミットレポートツール(コミネコ)初期コミット

Made-with: Cursor
This commit is contained in:
hiroki ito
2026-04-03 19:31:32 +09:00
commit c872debe4b
29 changed files with 4344 additions and 0 deletions
@@ -0,0 +1,260 @@
#!/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();