生成AIの進化が止まらない!
新技術を知るには手を動かすのが一番。
ということで、GWに1週間かけて生成AIでアプリをつくってみた。最近はvibe coding(バイブ・コーディング)というらしい。
その結果、AIとの対話で、1行もコードを書かずに、1万行のWEBアプリが出来た・・!
どんなアプリ?
bravesoftで起きていることを可視化して共有するアプリ
その名も「BRAVE FAN BOARD」。オフィスでディスプレイに表示。
bravesoft応援団がbraverを称えたり、Slack等でのアクティブ度を見やすく分析。
アプリを使っている動画がこちら。こんなアプリをAIと1週間でつくった。
僕もイチ推しbraverに登場。AIに褒められてなんかうれしい笑。
開発の進め方>Cursor+Claude3.7を導入
CursorはAIエージェントと対話しながら、環境構築・開発・実行・検証・バグの修正まで全部やってくれてかなり便利。AIと一緒にNode.js、TailwindCSS、GitHub環境を構築。これまではChatGPTなりAIツールにいろいろコピペする必要があった。Cursorなら環境全体をAIが把握・理解して操作・編集までしてくれる。強力なバディといった感じ。
AIにはChatGPTやGeminiも指定できるが、やっぱりClaude3.7が優秀と感じた。
1行たりともソースコードを触らないと誓う
やるからには完全にAIに開発してもらおう。ということで1行もコーディングせず。「こういうソースを書いて、とかデータはこのように保存して。デザインはもうちょっとココを大きく」などと人間に口頭で伝える形で完成までいけた。(ただ、コードやログをある程度理解したうえで指示を判断する局面も多々あり、コーディングの知識が薄い人にはまだまだ難しいのが現状)
データでみる開発
所要時間:50時間程度
画面数:30画面
AIとの会話数:だいたい500回
AIが書いたコード数:約1万行
必要経費:AIツール費1万円未満、会社にあった古いMAC,ディスプレイ
開発シーン動画
実際にCursorで開発しているシーンがこちら。エラーが起きてたので、エラー文を貼り付けて「このエラーを直して」という指示をだした。たったそれだけ。あとは完全自動。実行ボタンを押し続けているとエラーが直ってる。(うまくいかなくてやり直す時も多々ある)
感想)75点までは一瞬
ざっと伝えて動くものをつくるのがホントに早い。ざっくりした指示でOK。必要な関連機能も提案してくれてすぐ実装完了。例えばユーザ一覧やグラフ表示など、ホントに一瞬で出来た。さきにかっちり仕様を決めるのではなく、AIとつくりながら仕様を考えていく感じ。
100点にもっていくのは大変
本番で使えるように細かな最終調整をするのがとても大変だった。例えばデータ取得処理を実行するタイミングの変更。画像表示の細かな調整。ちょっと気の利いた機能を追加したい。などが大変。あっちを変えたらこっちがバグる現象も起きたり。思い通りにいかず、最終調整にかなり時間を取られた。
75点までは10時間。そこから100点にもってくまでに40時間かかるような感じだ。たとえばAIに絵を書いてもらう時も似たようなことが起こる。ざっくりこういう絵を書いて! はすぐに出来るけど。この絵のこの辺にこういう物体を置いて、という細かい指示をすると一気に精度が悪くなり試行回数が増えるあの感じ。
どんどん重くなり精度は悪くなる
ソースが数千行になってくるとAIの思考時間がどんどん長くなり、修正精度も落ちる。
後半戦の方は人間が編集したほうが早いと感じることが多かった。複雑なシステムの開発はまだ難しい。プロトやデータ表示がメインなダッシュボードなどには十分使えるが、設計やアーキテクチャなどちゃんと構築できるわけではないので、それ以上複雑なシステムはAIベースで開発するのは難しい。人間が設計し部分的なコーディングを補佐してもらうのが現在地点。
それでもやりきれた
AIと会話だけで日常利用できるアプリをつくることはけっこう大変だったが、それでもやりきれた。
普段はコーディングしていない人(=僕)が、一週間の会話だけでアプリを作れる時代。
課題や品質問題はあるとしても、まだまだこれからAIが賢くなると想定すると、人間がコーディングする必要性は無くなっていくように思える。機械語やアセンブリ言語を全エンジニアが理解する必要性がなくなったように、人間がコーディングをする必要性もなくなっていきそう。(一方でまだイマイチと感じることも多くどのくらい先なのかは未知数。焦って絶望しすぎないように)
エンジニアは上流へ向かえ!
ものづくりの本質はきっと変わらない。クリエイティブ、アイディア、デザイン、導入運用、継続改善など、より上流を人間が担当しAIを駆使して圧倒的なスピードでものづくりが出来るようになった。
僕が人生で一番コーディングしていたのは20年前学生バイトプログラマ時代。コーディング自体が楽しかった。22歳で起業してからは、仕様やデザインを考えたり、サービスやイベントを立ち上げたり、採用やカルチャーにコミットしたりと、ビジネスの上流で忙しくものづくりを追求してきた。
コーディング・技術全般を理解する人がビジネスで果たせる役割は大きい。AIに過度に期待せず能力のギリギリ最大限を引き出す。スマホの浸透のように10年かけて徐々に、(現場感では一気に)進める。
エンジニアは上流にどんどん向かいビジネスに向き合おう。クリエイティブやコミュニケーションを大切にしよう。これからものづくりがどのように変化するかは誰にもわからない。そして解決すべき課題は盛りだくさん。政治、貧困、戦争、健康、老後、地方、孤独、教育、無気力・・等々課題山積。AIを使ったエンジニアリングで全部解決していこう! 変化の先端に立ち続け、人々の幸福に貢献していくのみ。
おまけ)ソースコードを大公開!
ソースコードのメイン部分(index.js)を公開。これがAIが書いた5000行だ!
冗長だったり肥大化してるけどご愛嬌!
const express = require('express');
const dotenv = require('dotenv');
const path = require('path');
const cors = require('cors');
const { WebClient } = require('@slack/web-api');
const axios = require('axios');
const { Anthropic } = require('@anthropic-ai/sdk');
const OpenAI = require('openai');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const multer = require('multer');
const { google } = require('googleapis');
const { JWT } = require('google-auth-library');
// 日本時間の現在日時を取得するヘルパー関数
function getJSTDate() {
const now = new Date();
return new Date(now.getTime() + (9 * 60 * 60 * 1000)); // UTC+9時間(日本時間)
}
// 日本時間の現在日時からYYYY-MM-DD形式の日付文字列を取得
function getJSTDateString() {
return getJSTDate().toISOString().split('T')[0];
}
// 設定ファイルのパス
const SETTINGS_FILE_PATH = path.join(__dirname, 'settings.json');
// デフォルトの設定値
const defaultSettings = {
channelId: process.env.SLACK_CHANNEL_ID || '',
botToken: process.env.SLACK_BOT_TOKEN || ''
};
// デフォルトAI分析プロンプト
const defaultAiAnalysisPrompt = "あなたは優れた人材分析のプロフェッショナルです。Slackの会話データからユーザーの才能や強み、独自性を見出し、具体的なエピソードを交えながら解説する能力に優れています。分析では、ユーザー固有の特徴を具体的に指摘し、実際の会話からの例を引用しながら、その人だけが持つ価値を明らかにしてください。批判的な内容は一切含めず、明るく前向きな観点からその人の魅力を最大600文字で表現してください。";
// 現在のAI分析プロンプト(初期値はデフォルト)
let currentAiAnalysisPrompt = defaultAiAnalysisPrompt;
// アプリケーション起動時に設定ファイルを読み込む
try {
const settings = getSettings();
if (settings.aiAnalysisPrompt) {
currentAiAnalysisPrompt = settings.aiAnalysisPrompt;
console.log('設定ファイルからAI分析プロンプトを読み込みました');
}
} catch (error) {
console.error('設定ファイルの読み込みに失敗しました:', error);
}
// SQLiteデータベースを初期化
const db = new sqlite3.Database('./slack_data.db');
// データベースのテーブルを初期化
function initDatabase() {
console.log('データベースを初期化しています...');
// メッセージテーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS slack_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
user_id TEXT,
channel_id TEXT,
channel_name TEXT,
text TEXT,
message_type TEXT,
timestamp TEXT,
thread_ts TEXT,
is_reply INTEGER DEFAULT 0,
reactions TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 最終同期日テーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS last_sync_date (
id INTEGER PRIMARY KEY AUTOINCREMENT,
last_sync_date DATE UNIQUE,
last_sync_timestamp INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// スレッド返信テーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS slack_thread_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
parent_message_id TEXT,
user_id TEXT,
channel_id TEXT,
text TEXT,
timestamp TEXT,
reply_id TEXT,
thread_ts TEXT,
channel_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// スタンプ(リアクション)テーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS slack_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT,
channel_id TEXT,
user_id TEXT,
name TEXT,
timestamp TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// ユーザーテーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS slack_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT UNIQUE,
name TEXT,
real_name TEXT,
display_name TEXT,
avatar TEXT,
is_bot INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// チャンネルテーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS slack_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT UNIQUE,
name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// ユーザー分析結果テーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS user_analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
analysis_json TEXT,
analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 今日のイチ推しbraverテーブルの作成
db.run(`
CREATE TABLE IF NOT EXISTS daily_pickup_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
name TEXT,
real_name TEXT,
display_name TEXT,
avatar TEXT,
message_count INTEGER,
reply_count INTEGER,
attendance_count INTEGER,
office_count INTEGER,
report_count INTEGER,
sent_reaction_count INTEGER,
received_reaction_count INTEGER,
analysis TEXT,
pickup_date DATE DEFAULT CURRENT_DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('データベース初期化が完了しました');
}
// アプリ起動時にデータベース初期化
initDatabase();
// 環境変数の読み込み(必ず最初に実行)
dotenv.config();
// .renvファイルからも環境変数を読み込み
try {
if (fs.existsSync('.renv')) {
const renvContent = fs.readFileSync('.renv', 'utf8');
const renvLines = renvContent.split('\n');
renvLines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('=')) {
const [key, ...valueParts] = trimmedLine.split('=');
const value = valueParts.join('='); // APIキーにはしばしば = が含まれることがあるため
if (key && value) {
process.env[key.trim()] = value.trim();
console.log(`環境変数を.renvから読み込み: ${key.trim()} = 設定済み`);
}
}
});
console.log('.renvファイルからの環境変数読み込みが完了しました');
}
} catch (error) {
console.error('.renvファイルの読み込みに失敗しました:', error.message);
}
// Expressアプリの初期化
const app = express();
const port = process.env.PORT || 3000;
// Claude API設定を取得
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
// OpenAI API設定を取得
let openai = null;
if (process.env.OPENAI_API_KEY) {
openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
console.log('OpenAI APIキーが設定されました');
} else {
console.log('OpenAI APIキーが未設定です');
}
// CORSを有効化
app.use(cors({
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
// JSONボディパーサーを使用
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 静的ファイルの提供
app.use(express.static(path.join(__dirname, 'public')));
// Slack API設定を環境変数から取得
const slackToken = process.env.SLACK_BOT_TOKEN;
// 複数のチャンネルIDをサポート
let slackChannelIds = [];
// 環境変数からチャンネルIDを取得
if (process.env.SLACK_CHANNEL_IDS) {
// カンマ区切りの複数チャンネルIDをサポート
slackChannelIds = process.env.SLACK_CHANNEL_IDS.split(',').map(id => id.trim()).filter(id => id);
console.log(`環境変数から複数チャンネルIDを読み込みました: ${slackChannelIds.length}件`);
} else if (process.env.SLACK_CHANNEL_ID) {
// 後方互換性のために単一チャンネルIDもサポート
slackChannelIds = [process.env.SLACK_CHANNEL_ID];
console.log(`環境変数から単一チャンネルIDを読み込みました: ${process.env.SLACK_CHANNEL_ID}`);
// SLACK_CHANNEL_IDSがなければ設定(一貫性のため)
process.env.SLACK_CHANNEL_IDS = process.env.SLACK_CHANNEL_ID;
console.log('環境変数SLACK_CHANNEL_IDSを設定しました:', process.env.SLACK_CHANNEL_IDS);
}
// グローバル変数としても設定
global.slackChannelIds = slackChannelIds;
// 設定の検証
console.log('環境変数: SLACK_BOT_TOKEN', slackToken ? '設定済み' : '未設定');
console.log('環境変数: SLACK_CHANNEL_IDS', slackChannelIds.length > 0 ? `設定済み (${slackChannelIds.join(', ')})` : '未設定');
console.log('グローバル変数: slackChannelIds', global.slackChannelIds ? `設定済み (${global.slackChannelIds.join(', ')})` : '未設定');
// WebClientの初期化
const slackClient = new WebClient(slackToken);
// メッセージを分析する関数 (旧名:analyzeTextWithGPT)
async function analyzeTextWithAI(messages, userMap) {
try {
console.log('分析開始: 元のメッセージ数:', messages.length);
// 400文字以上のメッセージのみをフィルタリング
const longMessages = messages.filter(msg => {
return msg.text && msg.text.length >= 400;
});
console.log(`400文字以上のメッセージをフィルタリング: ${longMessages.length}件/${messages.length}件`);
// 分析対象がゼロの場合は早期リターン
if (longMessages.length === 0) {
console.log('400文字以上のメッセージが見つかりません。分析をスキップします。');
return {
summary: { mainTopics: [], overallSummary: "400文字以上のメッセージが見つかりませんでした。" },
trendAnalysis: { keywords: [], topicChanges: "分析なし", communicationPattern: "分析なし" },
userAnalysis: {},
insightsAndRecommendations: ["400文字以上の詳細な投稿がありません。"]
};
}
// 以降の処理では messages の代わりに longMessages を使用
// メッセージをユーザーごとにグループ化
const messagesByUser = {};
longMessages.forEach(msg => {
const userId = msg.user;
const userName = userMap[userId]?.name || 'Unknown User';
if (!messagesByUser[userName]) {
messagesByUser[userName] = [];
}
messagesByUser[userName].push({
text: msg.text,
ts: msg.ts,
timestamp: new Date(parseFloat(msg.ts) * 1000).toLocaleString()
});
});
// 時系列順にソートされたメッセージ
const sortedMessages = longMessages
.map(msg => ({
text: msg.text,
user: userMap[msg.user]?.name || 'Unknown User',
timestamp: new Date(parseFloat(msg.ts) * 1000)
}))
.sort((a, b) => a.timestamp - b.timestamp);
// 分析用のプロンプトを作成(基本情報)
const basePrompt = `
あなたはSlackのメッセージを分析する専門家です。以下のSlackチャンネルのメッセージのうち、400文字以上の詳細な投稿のみを分析して、次の点をJSON形式で詳細に回答してください:
1. 全体サマリー: 長文投稿の内容と傾向(主要トピック、議論の流れ、主な結論など)
2. 長文投稿の傾向分析: キーワード、トピックの特徴、表現の特徴など
3. ユーザー分析: 長文を投稿するユーザーのコミュニケーションスタイル、興味関心、役割など
データ情報:
- 長文メッセージ数: ${longMessages.length}件(全${messages.length}件中)
- 長文投稿ユーザー数: ${Object.keys(messagesByUser).length}名
- 期間: ${sortedMessages.length > 0 ?
`${sortedMessages[0].timestamp.toLocaleString()} から ${sortedMessages[sortedMessages.length-1].timestamp.toLocaleString()}` :
'不明'}
以下は各ユーザーの長文メッセージ例です:
`;
// ユーザーごとのメッセージ例を追加(各ユーザー最大5つのメッセージ)
let userMessagesPrompt = '';
Object.entries(messagesByUser).forEach(([userName, messages]) => {
userMessagesPrompt += `【${userName}】\n`;
messages
.slice(0, 5) // 最大5つまで
.forEach((msg, i) => {
userMessagesPrompt += `${i+1}. ${msg.timestamp}: ${msg.text}\n`;
});
userMessagesPrompt += '\n';
});
// 時系列分析用のプロンプト(最初から最後まで10件程度ピックアップ)
let timelinePrompt = '\n【時系列でのメッセージサンプル】\n';
const timelineSamples = [];
if (sortedMessages.length <= 10) {
// 10件以下なら全て
timelineSamples.push(...sortedMessages);
} else {
// 最初2件
timelineSamples.push(sortedMessages[0], sortedMessages[1]);
// 中間部分から数件
const middleStartIdx = Math.floor(sortedMessages.length * 0.3);
const middleEndIdx = Math.floor(sortedMessages.length * 0.7);
const step = Math.floor((middleEndIdx - middleStartIdx) / 4);
for (let i = middleStartIdx; i <= middleEndIdx; i += step) {
if (timelineSamples.length < 8) {
timelineSamples.push(sortedMessages[i]);
}
}
// 最後2件
timelineSamples.push(
sortedMessages[sortedMessages.length - 2],
sortedMessages[sortedMessages.length - 1]
);
}
timelineSamples.forEach((msg, i) => {
timelinePrompt += `${msg.timestamp.toLocaleString()}: ${msg.user}: ${msg.text}\n`;
});
// メッセージ全体から主要キーワードを抽出
const allMessages = longMessages.map(msg => msg.text).join(' ').toLowerCase();
const keywords = extractKeywords(allMessages);
const keywordsText = keywords.join(', ');
// レスポンス形式のガイドライン
const responseFormatPrompt = `
分析結果は以下のJSON形式で返してください:
{
"summary": {
"mainTopics": ["トピック1", "トピック2"],
"overallSummary": "長文投稿全体の要約...",
"keyConclusions": ["結論1", "結論2"]
},
"trendAnalysis": {
"keywords": ["キーワード1", "キーワード2"],
"topicChanges": "トピックの変化についての説明...",
"communicationPattern": "長文投稿のパターンの説明..."
},
"userAnalysis": {
"ユーザー名1": {
"communicationStyle": "長文投稿のスタイルの説明...",
"topics": ["興味のあるトピック1", "興味のあるトピック2"],
"role": "会話内での役割...",
"personality": "性格やコミュニケーション特性..."
},
"ユーザー名2": {
"communicationStyle": "長文投稿のスタイルの説明...",
"topics": ["興味のあるトピック1", "興味のあるトピック2"],
"role": "会話内での役割...",
"personality": "性格やコミュニケーション特性..."
}
},
"insightsAndRecommendations": [
"洞察1...",
"洞察2..."
]
}
キーワード抽出結果: ${keywordsText}
※この分析では、400文字以上の長文投稿のみを対象としています。
`;
// トピックの変化を検出
const topicChanges = detectTopicChanges(sortedMessages);
// アクティビティパターンを分析
const activityPattern = analyzeActivityPattern(sortedMessages);
// 送信前にトークン数をチェックし、必要に応じてメッセージを圧縮
const compressForTokenLimit = (userMessagesPrompt, timelinePrompt, maxTotalTokens = 100000) => {
// 簡易的なトークン数計算関数
const estimateTokens = (text) => {
const japaneseChars = text.match(/[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf]/g) || [];
const otherChars = text.length - japaneseChars.length;
return Math.ceil(japaneseChars.length / 2 + otherChars / 4);
};
// 各部分のトークン数を計算
const userMsgTokens = estimateTokens(userMessagesPrompt);
const timelineTokens = estimateTokens(timelinePrompt);
const baseAndFormatTokens = 3000; // システムプロンプトと構造部分のおおよどのトークン数
const totalEstimatedTokens = userMsgTokens + timelineTokens + baseAndFormatTokens;
console.log(`推定トークン数: ユーザーメッセージ=${userMsgTokens}, タイムライン=${timelineTokens}, 合計=${totalEstimatedTokens}`);
// トークン数が上限を超えていなければそのまま返す
if (totalEstimatedTokens <= maxTotalTokens) {
return { userMessagesPrompt, timelinePrompt, totalEstimatedTokens };
}
console.log(`トークン数が上限(${maxTotalTokens})を超えています。メッセージを圧縮します...`);
// 削減が必要な量を計算
const excessTokens = totalEstimatedTokens - maxTotalTokens;
const reduceRatio = 1 - (excessTokens / (userMsgTokens + timelineTokens));
// ユーザーメッセージとタイムラインを比例配分で削減
let compressedUserMsgs = userMessagesPrompt;
let compressedTimeline = timelinePrompt;
// 1. まずタイムラインを削減(最も削減しやすい)
if (timelineTokens > 1000) {
// タイムラインを大幅に削減
const timelineLines = timelinePrompt.split('\n');
const keptLines = Math.max(5, Math.floor(timelineLines.length * reduceRatio));
// 重要なポイントのみを残す(先頭、中間、末尾)
const selectedLines = [];
selectedLines.push(timelineLines[0]); // ヘッダー
if (timelineLines.length > 2) {
selectedLines.push(timelineLines[1]); // 最初のサンプル
// 中間のサンプルから数個(均等間隔)
const step = Math.floor((timelineLines.length - 3) / (keptLines - 3));
for (let i = 0; i < keptLines - 3; i++) {
const idx = 1 + step * (i + 1);
if (idx < timelineLines.length - 1) {
selectedLines.push(timelineLines[idx]);
}
}
selectedLines.push(timelineLines[timelineLines.length - 1]); // 最後のサンプル
}
compressedTimeline = selectedLines.join('\n');
console.log(`タイムラインを ${timelineLines.length} 行から ${selectedLines.length} 行に削減しました`);
}
// 2. 次にユーザーメッセージを削減(各ユーザーのサンプル数を減らす)
if (estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens > maxTotalTokens) {
// ユーザーごとのブロックに分割
const userBlocks = compressedUserMsgs.split(/【(.+?)】/).filter(block => block.trim());
const userSections = [];
for (let i = 0; i < userBlocks.length; i += 2) {
if (i + 1 < userBlocks.length) {
userSections.push({
user: userBlocks[i],
content: userBlocks[i+1]
});
}
}
console.log(`ユーザーセクション数: ${userSections.length}`);
// 各ユーザーのメッセージサンプルを削減
const compressedSections = userSections.map(section => {
const messages = section.content.split(/\d+\. /).filter(msg => msg.trim());
if (messages.length <= 2) return `【${section.user}】\n${section.content}`;
// サンプル数を2つだけに削減
const firstSample = messages[1]; // 0番目は空か前のセパレータの可能性
const lastSample = messages[messages.length - 1];
return `【${section.user}】\n1. ${firstSample}\n2. ${lastSample}\n`;
});
compressedUserMsgs = compressedSections.join('\n');
console.log('各ユーザーのサンプル数を削減しました');
}
// 3. それでも多すぎる場合はユーザー数を削減
if (estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens > maxTotalTokens) {
// ユーザーごとのブロックに分割
const userBlocks = compressedUserMsgs.split(/【(.+?)】/).filter(block => block.trim());
const userSections = [];
for (let i = 0; i < userBlocks.length; i += 2) {
if (i + 1 < userBlocks.length) {
userSections.push({
user: userBlocks[i],
content: userBlocks[i+1]
});
}
}
// ユーザー数を削減(メッセージ数の多い上位のみを残す)
const maxUsers = Math.max(5, Math.floor(userSections.length * 0.5));
const keptSections = userSections.slice(0, maxUsers);
compressedUserMsgs = keptSections.map(section => `【${section.user}】\n${section.content}`).join('\n');
console.log(`ユーザー数を ${userSections.length} から ${keptSections.length} に削減しました`);
}
// 最終的なトークン数を再計算
const finalTokens = estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens;
console.log(`圧縮後の推定トークン数: ${finalTokens}`);
return {
userMessagesPrompt: compressedUserMsgs,
timelinePrompt: compressedTimeline,
totalEstimatedTokens: finalTokens
};
};
// OpenAIを使って分析
if (openai && process.env.OPENAI_API_KEY) {
try {
console.log('OpenAIを使用して分析を開始します...');
console.log('OpenAI APIキー確認:', process.env.OPENAI_API_KEY ? 'APIキー設定済み' : 'APIキー未設定');
console.log('メッセージ数:', longMessages.length);
console.log('ユーザー数:', Object.keys(messagesByUser).length);
if (!process.env.OPENAI_API_KEY) {
throw new Error('OpenAI APIキーが設定されていません');
}
// システムプロンプトとユーザープロンプトを作成
const systemPrompt = "あなたは優秀な人材分析のプロです。Slackの会話から具体的なエピソード、案件名、人名を使って600文字程度で簡潔に分析してください。1) 関わった具体的なプロジェクト、2) 発言から見える専門性、3) チーム貢献の実例、4) コミュニケーションの特徴的なパターン、5) 具体的な強みを必ず含めてください。簡潔な文体で、具体的なエピソードを根拠にした分析を行ってください。";
// トークン数の削減処理を適用
const { userMessagesPrompt: compressedUserMsgs, timelinePrompt: compressedTimeline, totalEstimatedTokens } =
compressForTokenLimit(userMessagesPrompt, timelinePrompt, 8000); // GPT-4は入力トークン制限が低いため調整
// 最終的なプロンプトを作成(圧縮バージョンを使用)
const finalPrompt = basePrompt + compressedUserMsgs + compressedTimeline + responseFormatPrompt;
console.log('OpenAIリクエストを送信します...');
console.log(`推定トークン数: ${totalEstimatedTokens}`);
try {
// OpenAI APIを呼び出す(トークン制限に配慮)
const response = await openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.2,
max_tokens: 3000,
messages: [
{
role: "system",
content: "あなたは優秀な人材分析のプロです。Slackの会話から具体的なエピソード、案件名、人名を使って600文字程度で簡潔に分析してください。1) 関わった具体的なプロジェクト、2) 発言から見える専門性、3) チーム貢献の実例、4) コミュニケーションの特徴的なパターン、5) 具体的な強みを必ず含めてください。簡潔な文体で、具体的なエピソードを根拠にした分析を行ってください。"
},
{
role: "user",
content: finalPrompt
}
]
});
console.log('OpenAIからの応答を受信しました');
// OpenAI APIからの応答を処理
if (response && response.choices && response.choices.length > 0) {
const aiResponse = response.choices[0].message.content;
console.log('応答内容の一部:', aiResponse.substring(0, 100) + '...');
// JSONをパース
let analysisResult;
try {
// 前後の余分なテキストを削除して純粋なJSONだけを抽出
let jsonString = aiResponse.trim();
// もし```json```で囲まれていたら、その部分だけを抽出
const jsonBlockMatch = jsonString.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (jsonBlockMatch && jsonBlockMatch[1]) {
jsonString = jsonBlockMatch[1].trim();
console.log('マークダウンコードブロックから純粋なJSONを抽出しました');
}
// JSON解析
analysisResult = JSON.parse(jsonString);
console.log('JSON解析成功');
return analysisResult;
} catch (parseError) {
console.error('JSON解析エラー:', parseError.message);
// JSON修正を試みる
try {
const fixedJsonString = cleanupAllProblematicCharacters(aiResponse);
analysisResult = JSON.parse(fixedJsonString);
console.log('JSON修正後の解析成功');
return analysisResult;
} catch (fixError) {
console.error('JSON修正後も解析失敗:', fixError.message);
// 簡易分析に切り替え
return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
}
}
} else {
console.warn('OpenAIの応答形式が想定外です');
return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
}
} catch (apiError) {
console.error('OpenAI API呼び出しエラー:', apiError);
return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
}
} catch (error) {
console.warn('OpenAI APIエラー:', error.message);
return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
}
}
// OpenAIが使えない場合のフォールバック
console.warn('OpenAIが使用できないため、簡易分析を使用します');
return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
} catch (error) {
console.error('テキスト分析中にエラーが発生しました:', error);
return {
error: 'テキスト分析に失敗しました',
details: error.message
};
}
}
// Claudeが使えない場合または失敗した場合のフォールバック分析関数
function createSimpleAnalysis(messages, messagesByUser, keywords, topicChanges, activityPattern) {
console.warn('簡易分析を使用します(Claudeは使用しません)');
const analysisResult = {
channelInsights: "このチャンネルは主にプロジェクト関連の技術的な議論と更新情報の共有に使用されています。会話はプロフェッショナルでありながらもカジュアルなトーンです。",
channelSummary: {
mainKeywords: keywords,
topicChanges: topicChanges,
activityPattern: activityPattern
},
userAnalysis: {}
};
// 各ユーザーのテキストを簡易分析
Object.entries(messagesByUser).forEach(([userName, messages]) => {
const joinedText = messages.map(m => m.text).join(' ').toLowerCase();
let style = "";
let interests = "";
let personality = "";
// 簡易的なキーワード分析
if (joinedText.includes('問題') || joinedText.includes('エラー')) {
style += "問題解決指向、";
personality += "分析的、";
}
if (joinedText.includes('思う') || joinedText.includes('考え')) {
style += "内省的、";
personality += "思慮深い、";
}
if (joinedText.includes('!') || joinedText.includes('?')) {
style += "質問が多い、";
personality += "好奇心旺盛、";
}
if (joinedText.includes('api') || joinedText.includes('コード')) {
interests += "プログラミング、";
}
if (joinedText.includes('slack') || joinedText.includes('メッセージ')) {
interests += "コミュニケーションツール、";
}
analysisResult.userAnalysis[userName] = {
communicationStyle: style ? style.slice(0, -1) : "データ不足で判断できません",
topics: interests ? interests.slice(0, -1).split('、') : ["データ不足"],
role: messages.length > (messages.length / Object.keys(messagesByUser).length) * 1.5
? "主要な発言者"
: "通常の参加者",
personality: personality ? personality.slice(0, -1) : "データ不足で判断できません",
messageCount: messages.length,
averageLength: Math.round(messages.map(m => m.text).join(' ').length / messages.length)
};
});
return analysisResult;
}
// メッセージからキーワードを抽出する補助関数
function extractKeywords(text) {
// 簡易的なキーワード抽出(実際の実装ではもっと洗練された方法を使用)
const commonWords = ['て', 'に', 'は', 'を', 'の', 'が', 'と', 'です', 'ます', 'した', 'から', 'ない', 'いる', 'ある', 'れる'];
const words = text.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, '').toLowerCase().split(/\s+/);
const wordCounts = {};
words.forEach(word => {
if (word.length > 1 && !commonWords.includes(word)) {
wordCounts[word] = (wordCounts[word] || 0) + 1;
}
});
return Object.entries(wordCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(entry => entry[0]);
}
// メッセージからトピックの変化を検出する補助関数
function detectTopicChanges(messages) {
if (messages.length < 3) return "メッセージが少ないため、トピックの変化を検出できません";
// 簡易的なトピック変化検出
const firstThirdKeywords = extractKeywords(messages.slice(0, Math.floor(messages.length / 3)).map(m => m.text).join(' '));
const lastThirdKeywords = extractKeywords(messages.slice(-Math.floor(messages.length / 3)).map(m => m.text).join(' '));
// キーワードの違いを確認
const differentKeywords = lastThirdKeywords.filter(kw => !firstThirdKeywords.includes(kw));
if (differentKeywords.length > 0) {
return `会話の途中でトピックが変化した可能性があります。最近のキーワード: ${differentKeywords.join(', ')}`;
} else {
return "会話全体を通して一貫したトピックが維持されています";
}
}
// メッセージのアクティビティパターンを分析する補助関数
function analyzeActivityPattern(messages) {
if (messages.length < 3) return "メッセージが少ないため、アクティビティパターンを分析できません";
// メッセージの時間間隔を計算
const intervals = [];
for (let i = 1; i < messages.length; i++) {
intervals.push(messages[i].timestamp - messages[i-1].timestamp);
}
const avgInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length;
// 最も活発な参加者を特定
const userCounts = {};
messages.forEach(msg => {
userCounts[msg.user] = (userCounts[msg.user] || 0) + 1;
});
const mostActiveUser = Object.entries(userCounts)
.sort((a, b) => b[1] - a[1])[0][0];
// 返却するパターン情報
return {
averageTimeBetweenMessages: Math.round(avgInterval) + "秒",
mostActiveUser: mostActiveUser,
conversationPace: avgInterval < 60 ? "非常に活発な会話" :
avgInterval < 300 ? "活発な会話" :
avgInterval < 1800 ? "通常のペースの会話" : "間隔の空いた会話"
};
}
// シンプルテストページ(デバッグ用)
app.get('/simple', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Slack テスト</title>
<style>
body { font-family: Arial; max-width: 800px; margin: 0 auto; padding: 20px; background: #f4f4f4; }
button { padding: 10px; background: #4CAF50; color: white; border: none; cursor: pointer; margin: 5px; }
#messages { margin-top: 20px; border: 1px solid #ddd; padding: 15px; min-height: 200px; background: white; }
</style>
</head>
<body>
<h1>Slack テスト</h1>
<button id="getSlackMessages">Slackメッセージを取得</button>
<div id="messages">ここに結果が表示されます...</div>
<script>
document.getElementById('getSlackMessages').addEventListener('click', async () => {
const messagesDiv = document.getElementById('messages');
messagesDiv.innerHTML = 'Slackメッセージ読み込み中...';
try {
const response = await fetch('/api/slack-messages');
const data = await response.json();
if (data.error) {
messagesDiv.innerHTML = '<p>エラー: ' + data.error + '</p>';
return;
}
let html = '<h3>Slackメッセージ取得成功!</h3>';
html += '<ul>';
data.forEach(msg => {
html += '<li>';
html += '<strong>' + (msg.userName || 'Unknown User') + '</strong> ';
html += '<span style="color: #888;">(' + msg.timestamp + ')</span>: ';
html += msg.text;
html += '</li>';
});
html += '</ul>';
messagesDiv.innerHTML = html;
} catch (error) {
messagesDiv.innerHTML = '<p>エラー: ' + error.message + '</p>';
}
});
</script>
</body>
</html>
`);
});
// Slackからメッセージを取得するAPI
app.get('/api/slack-messages', async (req, res) => {
try {
const requestedChannelId = req.query.channelId;
// チャンネルIDが指定されているか確認
let targetChannelId;
if (requestedChannelId) {
// リクエストで指定されたチャンネルIDを使用
targetChannelId = requestedChannelId;
console.log(`リクエストで指定されたチャンネルID: ${targetChannelId}`);
} else if (global.slackChannelIds && global.slackChannelIds.length > 0) {
// グローバル変数からチャンネルIDを取得
targetChannelId = global.slackChannelIds[0];
console.log(`グローバル変数からのデフォルトチャンネルID: ${targetChannelId}`);
} else if (slackChannelIds.length > 0) {
// デフォルトは最初のチャンネルを使用
targetChannelId = slackChannelIds[0];
console.log(`デフォルトチャンネルID: ${targetChannelId}`);
} else {
console.error('Slack API設定が不足しています');
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
console.log(`チャンネル ${targetChannelId} からSlackメッセージを取得します...`);
// チャンネル情報を取得
let channelName = 'Slack Channel';
try {
console.log(`チャンネル ${targetChannelId} からSlackメッセージを取得します...`);
// チャンネル情報を取得
let channelName = 'Slack Channel';
try {
const channelInfo = await slackClient.conversations.info({
channel: targetChannelId
});
channelName = channelInfo.channel.name || 'Slack Channel';
console.log(`チャンネル名: ${channelName}`);
} catch (channelError) {
console.error('チャンネル情報取得エラー:', channelError);
}
// チャンネル履歴を取得
const result = await slackClient.conversations.history({
channel: targetChannelId,
cursor: cursor,
limit: 200, // 1回のリクエストで最大200件
oldest: Math.max(oldestTimestamp, latestMsg || 0), // 3ヶ月前か最新の保存済みメッセージの新しい方を基準にする
include_all_metadata: true // リアクション情報などすべてのメタデータを含める
});
// APIレスポンスのサンプルをログに出力(デバッグ用)
if (result.messages && result.messages.length > 0) {
console.log(`APIレスポンスサンプル: 最初のメッセージのフィールド一覧:`, Object.keys(result.messages[0]).join(', '));
// リアクションを含むメッセージがあるか確認
const msgWithReactions = result.messages.find(msg => msg.reactions && msg.reactions.length > 0);
if (msgWithReactions) {
console.log(`リアクションを含むメッセージ例:`, JSON.stringify(msgWithReactions.reactions, null, 2));
} else {
console.log(`リアクションを含むメッセージが見つかりませんでした`);
}
}
console.log(`${result.messages.length}件のメッセージを取得しました`);
// メッセージを整形
const messages = result.messages.map(msg => {
return {
text: msg.text,
user: msg.user,
ts: msg.ts,
timestamp: new Date(parseFloat(msg.ts) * 1000).toLocaleString(),
channelId: targetChannelId,
channelName: channelName
};
});
// ユーザー情報を取得してメッセージに追加
const userIds = [...new Set(messages.map(msg => msg.user).filter(id => id && typeof id === 'string' && id !== 'undefined' && id !== 'null'))];
const userPromises = userIds.map(async userId => {
try {
// nullまたは無効なユーザーIDをチェック
if (!userId || userId === 'null' || userId === 'undefined') {
console.warn(`無効なユーザーID '${userId}' をスキップします`);
return { id: userId || 'unknown', name: 'Unknown User', avatar: '' };
}
const userInfo = await slackClient.users.info({ user: userId });
if (!userInfo || !userInfo.user) {
console.warn(`ユーザー情報が見つかりません: ${userId}`);
return { id: userId, name: 'Unknown User', avatar: '' };
}
return {
id: userId,
name: userInfo.user.real_name || userInfo.user.name || 'Unknown User',
avatar: userInfo.user.profile?.image_72 || ''
};
} catch (error) {
console.error(`ユーザー情報取得エラー (${userId}):`, error);
return { id: userId || 'unknown', name: 'Unknown User', avatar: '' };
}
});
const users = await Promise.all(userPromises);
const userMap = {};
users.forEach(user => {
if (user && user.id) {
userMap[user.id] = user;
}
});
// ユーザー情報をメッセージに追加
const messagesWithUserInfo = messages.map(msg => {
const userId = msg.user || 'unknown';
return {
...msg,
userName: userMap[userId]?.name || 'Unknown User',
userAvatar: userMap[userId]?.avatar || ''
};
});
res.json(messagesWithUserInfo);
} catch (error) {
console.error('Slackメッセージ取得エラー:', error);
if (error.data) {
console.error('エラー詳細:', JSON.stringify(error.data));
}
res.status(500).json({ error: 'Slackメッセージの取得に失敗しました' });
}
} catch (error) {
console.error('Slackメッセージ取得の外部エラー:', error);
res.status(500).json({ error: 'Slackメッセージの取得に失敗しました' });
}
});
// メッセージ分析のAPI
app.get('/api/analyze-messages', async (req, res) => {
try {
const { channelId, userId } = req.query;
console.log(`分析リクエスト: チャンネル=${channelId}, ユーザー=${userId || '未指定(全ユーザー)'}`);
if (!channelId) {
return res.status(400).json({ error: 'チャンネルIDが指定されていません' });
}
// 簡易分析結果を返す
return res.json({
success: true,
source: "simple",
messageCount: 0,
filteredMessageCount: 0,
userCount: 0,
channelName: "分析機能は現在無効化されています",
summary: {
mainTopics: ["機能無効化中"],
overallSummary: "AI分析機能は現在無効化されています。"
},
trendAnalysis: {
keywords: ["無効化", "メンテナンス中"],
topicChanges: "分析なし",
communicationPattern: "分析なし"
},
userAnalysis: {},
insightsAndRecommendations: ["現在、分析機能はメンテナンス中のため利用できません。"]
});
} catch (error) {
console.error('メッセージ分析中にエラーが発生しました:', error);
res.status(500).json({ error: error.message });
}
});
// APIキー設定画面
app.get('/settings', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'settings.html'));
});
// APIキー設定を保存するエンドポイント
app.post('/api/settings', express.json(), (req, res) => {
try {
const { anthropicApiKey } = req.body;
if (!anthropicApiKey) {
return res.status(400).json({ error: 'APIキーは必須です' });
}
// .renvファイルを読み込む
let envContent = '';
try {
envContent = fs.readFileSync('.renv', 'utf8');
} catch (error) {
console.error('.renvファイルの読み込みに失敗しました:', error.message);
envContent = 'SLACK_BOT_TOKEN=\nSLACK_CHANNEL_ID=\nOPENAI_API_KEY=\nANTHROPIC_API_KEY=\n';
}
// ANTHROPIC_API_KEYの行を置き換える
const lines = envContent.split('\n');
let found = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('ANTHROPIC_API_KEY=')) {
lines[i] = `ANTHROPIC_API_KEY=${anthropicApiKey}`;
found = true;
break;
}
}
// APIキーの行が見つからなかった場合は追加
if (!found) {
lines.push(`ANTHROPIC_API_KEY=${anthropicApiKey}`);
}
// .renvファイルに書き込む
fs.writeFileSync('.renv', lines.join('\n'));
// 保存した内容をログに出力(デバッグ用)
console.log('保存した.renvファイルの内容:');
console.log(lines.join('\n'));
// 環境変数を更新
process.env.ANTHROPIC_API_KEY = anthropicApiKey;
// Anthropic clientを再初期化
try {
anthropic.apiKey = anthropicApiKey;
console.log('Anthropic APIキーを更新しました');
} catch (error) {
console.error('Anthropic クライアントの再初期化に失敗しました:', error.message);
}
res.json({ success: true, message: 'APIキーを保存しました' });
} catch (error) {
console.error('APIキーの保存に失敗しました:', error.message);
res.status(500).json({ error: 'APIキーの保存に失敗しました' });
}
});
// 設定情報を取得するAPI
app.get('/api/settings/info', (req, res) => {
try {
// 設定ファイルから情報を取得
const settings = getSettings();
// Slackトークンの存在確認
const slackBotTokenSet = !!settings.slackBotToken;
// Slackチャンネルの存在確認
const slackChannelIdsSet = settings.slackChannelIds && settings.slackChannelIds.length > 0;
// Anthropic APIキーの存在確認
const anthropicApiKeySet = !!settings.anthropicApiKey;
// OpenAI APIキーの存在確認
const openaiApiKeySet = !!settings.openaiApiKey;
// センシティブな情報はマスクして返す
const slackBotTokenMasked = slackBotTokenSet ?
maskValue(settings.slackBotToken) : null;
const anthropicApiKeyMasked = anthropicApiKeySet ?
maskValue(settings.anthropicApiKey) : null;
const openaiApiKeyMasked = openaiApiKeySet ?
maskValue(settings.openaiApiKey) : null;
// キャラクター画像パスを追加
const characterImage = settings.characterImage || '/images/brave-character.png';
res.json({
slackBotTokenSet,
slackChannelIdsSet,
anthropicApiKeySet,
openaiApiKeySet,
slackBotTokenMasked,
anthropicApiKeyMasked,
openaiApiKeyMasked,
slackChannelIds: settings.slackChannelIds || defaultSettings.slackChannelIds || process.env.SLACK_CHANNEL_IDS || '',
slackBotToken: settings.slackBotToken || defaultSettings.botToken || process.env.SLACK_BOT_TOKEN || '',
slackSigningSecret: settings.slackSigningSecret || process.env.SLACK_SIGNING_SECRET || '',
anthropicApiKey: settings.anthropicApiKey || process.env.ANTHROPIC_API_KEY || '',
openaiApiKey: settings.openaiApiKey || process.env.OPENAI_API_KEY || '',
aiAnalysisPrompt: settings.aiAnalysisPrompt || defaultAiAnalysisPrompt,
characterImage
});
} catch (error) {
console.error('設定情報取得エラー:', error);
res.status(500).json({ error: '設定情報の取得に失敗しました' });
}
});
// Slack設定を更新するAPI
app.post('/api/settings/slack', express.json(), (req, res) => {
try {
const { slackBotToken, slackChannelIds } = req.body;
// 必須項目チェック
if (!slackChannelIds || !Array.isArray(slackChannelIds) || slackChannelIds.length === 0) {
return res.status(400).json({ error: '少なくとも1つのチャンネルIDが必要です' });
}
// .renvファイルを読み込む
let envContent;
try {
envContent = fs.readFileSync('.renv', 'utf8');
} catch (error) {
console.error('.renvファイルの読み込みに失敗しました:', error.message);
envContent = ''; // ファイルが存在しない場合は空文字で初期化
}
const lines = envContent.split('\n').filter(line => line.trim() !== '');
// 既存の設定を更新または新規追加
let slackBotTokenFound = false;
let slackChannelIdsFound = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('SLACK_BOT_TOKEN=')) {
if (slackBotToken) {
lines[i] = `SLACK_BOT_TOKEN=${slackBotToken}`;
}
slackBotTokenFound = true;
}
// 新形式の複数チャンネルIDの設定
if (lines[i].startsWith('SLACK_CHANNEL_IDS=')) {
lines[i] = `SLACK_CHANNEL_IDS=${slackChannelIds.join(',')}`;
slackChannelIdsFound = true;
}
// 旧形式の単一チャンネルIDを削除(または更新)
if (lines[i].startsWith('SLACK_CHANNEL_ID=')) {
if (slackChannelIdsFound) {
// 複数チャンネルIDがすでに設定されていれば、この行を削除
lines.splice(i, 1);
i--; // インデックスを調整
} else {
// 複数チャンネルIDが設定されていなければ、デフォルトチャンネルとして設定
lines[i] = `SLACK_CHANNEL_ID=${slackChannelIds[0]}`;
}
}
}
// 設定が存在しない場合は新規追加
if (!slackBotTokenFound && slackBotToken) {
lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`);
}
if (!slackChannelIdsFound) {
lines.push(`SLACK_CHANNEL_IDS=${slackChannelIds.join(',')}`);
// 後方互換性のために最初のチャンネルIDをデフォルトとして設定
lines.push(`SLACK_CHANNEL_ID=${slackChannelIds[0]}`);
}
// .renvファイルに書き込む
fs.writeFileSync('.renv', lines.join('\n'));
// 環境変数にも直接設定
if (slackBotToken) {
process.env.SLACK_BOT_TOKEN = slackBotToken;
}
process.env.SLACK_CHANNEL_IDS = slackChannelIds.join(',');
process.env.SLACK_CHANNEL_ID = slackChannelIds[0]; // 後方互換性のためにデフォルトとして設定
// グローバル変数を更新
global.slackChannelIds = slackChannelIds;
// グローバル変数の更新確認(デバッグ用)
console.log('グローバル変数slackChannelIdsを更新しました:', JSON.stringify(global.slackChannelIds));
// 成功レスポンス
res.json({
success: true,
message: 'Slack設定を更新しました',
channelIds: slackChannelIds
});
} catch (error) {
console.error('Slack設定更新エラー:', error);
res.status(500).json({ error: 'Slack設定の更新に失敗しました: ' + error.message });
}
});
// Anthropic APIキーを更新するAPI
app.post('/api/settings/anthropic', express.json(), (req, res) => {
try {
const { anthropicApiKey } = req.body;
if (!anthropicApiKey) {
return res.status(400).json({ error: 'APIキーは必須です' });
}
// .renvファイルを読み込む
let envContent = '';
try {
envContent = fs.readFileSync('.renv', 'utf8');
} catch (error) {
console.error('.renvファイルの読み込みに失敗しました:', error.message);
envContent = 'SLACK_BOT_TOKEN=\nSLACK_CHANNEL_ID=\nOPENAI_API_KEY=\nANTHROPIC_API_KEY=\n';
}
// ANTHROPIC_API_KEYの行を置き換える
const lines = envContent.split('\n');
let found = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('ANTHROPIC_API_KEY=')) {
lines[i] = `ANTHROPIC_API_KEY=${anthropicApiKey}`;
found = true;
break;
}
}
// APIキーの行が見つからなかった場合は追加
if (!found) {
lines.push(`ANTHROPIC_API_KEY=${anthropicApiKey}`);
}
// .renvファイルに書き込む
fs.writeFileSync('.renv', lines.join('\n'));
// 保存した内容をログに出力(デバッグ用)
console.log('保存した.renvファイルの内容:');
console.log(lines.join('\n'));
// 環境変数を更新
process.env.ANTHROPIC_API_KEY = anthropicApiKey;
// Anthropic clientを再初期化
try {
anthropic.apiKey = anthropicApiKey;
console.log('Anthropic APIキーを更新しました');
} catch (error) {
console.error('Anthropic クライアントの再初期化に失敗しました:', error.message);
}
res.json({ success: true, message: 'Anthropic APIキーを更新しました' });
} catch (error) {
console.error('Anthropic APIキーの更新に失敗しました:', error.message);
res.status(500).json({ error: 'Anthropic APIキーの更新に失敗しました' });
}
});
// OpenAI APIキーを更新するAPI
app.post('/api/settings/openai', express.json(), (req, res) => {
try {
const { openaiApiKey } = req.body;
if (!openaiApiKey) {
return res.status(400).json({ error: 'APIキーが指定されていません' });
}
// 環境変数を更新
process.env.OPENAI_API_KEY = openaiApiKey;
// .env ファイルがある場合は更新
const envPath = path.join(__dirname, '.renv');
if (fs.existsSync(envPath)) {
// 既存の .env ファイルを読み込む
const envFileContent = fs.readFileSync(envPath, 'utf8');
// OPENAI_API_KEY の行があれば更新、なければ追加
let updatedContent;
if (envFileContent.match(/OPENAI_API_KEY=/)) {
updatedContent = envFileContent.replace(/OPENAI_API_KEY=.*/, `OPENAI_API_KEY=${openaiApiKey}`);
} else {
updatedContent = envFileContent + `\nOPENAI_API_KEY=${openaiApiKey}`;
}
// 更新した内容で .env ファイルを上書き
fs.writeFileSync(envPath, updatedContent);
console.log('OpenAI APIキーが設定されました');
} else {
console.warn('.env ファイルが存在しないため、メモリ上のみで環境変数を更新しました');
}
res.json({ success: true });
} catch (error) {
console.error('OpenAI APIキー設定中にエラーが発生しました:', error);
res.status(500).json({ error: error.message });
}
});
// AI分析プロンプト設定エンドポイント
app.post('/api/settings/ai-prompt', express.json(), (req, res) => {
try {
const { aiAnalysisPrompt } = req.body;
// 空の場合はデフォルト値に戻す
if (!aiAnalysisPrompt) {
currentAiAnalysisPrompt = defaultAiAnalysisPrompt;
// 設定を更新して保存
const settings = getSettings();
settings.aiAnalysisPrompt = defaultAiAnalysisPrompt;
saveSettings(settings);
return res.json({
success: true,
message: 'AI分析プロンプトがデフォルト値に戻されました'
});
}
// プロンプトを更新
currentAiAnalysisPrompt = aiAnalysisPrompt;
// 設定を更新して保存
const settings = getSettings();
settings.aiAnalysisPrompt = aiAnalysisPrompt;
saveSettings(settings);
console.log('AI分析プロンプトが更新されました');
res.json({
success: true,
message: 'AI分析プロンプトが正常に更新されました'
});
} catch (error) {
console.error('AI分析プロンプト設定中にエラーが発生しました:', error);
res.status(500).json({ error: error.message });
}
});
// AI分析プロンプトをリセットするエンドポイント
app.post('/api/settings/reset-ai-prompt', express.json(), (req, res) => {
try {
// デフォルトプロンプトに戻す
currentAiAnalysisPrompt = defaultAiAnalysisPrompt;
// 設定を更新して保存
const settings = getSettings();
settings.aiAnalysisPrompt = defaultAiAnalysisPrompt;
saveSettings(settings);
console.log('AI分析プロンプトがリセットされました');
// デフォルトプロンプトをログに出力して確認
console.log('デフォルトプロンプト (最初の50文字):', defaultAiAnalysisPrompt.substring(0, 50) + '...');
// レスポンスを返す
res.json({
success: true,
message: 'AI分析プロンプトがデフォルト値に正常にリセットされました',
aiAnalysisPrompt: defaultAiAnalysisPrompt // デフォルトプロンプトをレスポンスに含める
});
} catch (error) {
console.error('AI分析プロンプトリセット中にエラーが発生しました:', error);
res.status(500).json({ error: error.message });
}
});
// データリセットエンドポイント
app.post('/api/settings/reset-data', express.json(), (req, res) => {
try {
console.log('データベースのリセットを開始します...');
// 各テーブルのデータを削除
db.run('DELETE FROM slack_messages');
db.run('DELETE FROM slack_thread_replies');
db.run('DELETE FROM slack_reactions');
db.run('DELETE FROM slack_channels');
db.run('DELETE FROM daily_pickup_user');
db.run('DELETE FROM user_analysis');
// シーケンスリセット (SQLiteの場合)
db.run('DELETE FROM sqlite_sequence WHERE name IN (\'slack_messages\', \'slack_thread_replies\', \'slack_reactions\', \'user_analysis\', \'daily_pickup_user\')');
console.log('データベースのリセットが完了しました');
res.json({
success: true,
message: 'データベースが正常にリセットされました。ローカルストレージの同期日時情報も削除されます。'
});
} catch (error) {
console.error('データベースリセットエラー:', error);
res.status(500).json({ error: 'データベースのリセットに失敗しました: ' + error.message });
}
});
// センシティブな値をマスクする関数
function maskValue(value) {
if (!value) return null;
if (value.length > 12) {
return value.substring(0, 6) + '...' + value.substring(value.length - 4);
} else {
return '***' + value.substring(value.length - 4);
}
}
// キャラクター画像保存先のディレクトリ設定
const characterStorage = multer.diskStorage({
destination: function(req, file, cb) {
const uploadDir = path.join(__dirname, 'public', 'images');
// ディレクトリが存在しない場合は作成
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function(req, file, cb) {
// ファイル名を brave-character + 拡張子に設定(上書き)
const extname = path.extname(file.originalname);
cb(null, 'brave-character-custom' + extname);
}
});
// キャラクター画像アップロード用のmulterインスタンス
const uploadCharacter = multer({
storage: characterStorage,
limits: {
fileSize: 2 * 1024 * 1024 // 2MB制限
},
fileFilter: function(req, file, cb) {
// 画像ファイルのみ許可
const filetypes = /jpeg|jpg|png|gif|svg/;
const mimetype = filetypes.test(file.mimetype);
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
if (mimetype && extname) {
return cb(null, true);
}
cb(new Error('画像ファイル(jpeg, jpg, png, gif, svg)のみアップロードできます'));
}
});
// キャラクター画像アップロードAPI
app.post('/api/settings/upload-character', uploadCharacter.single('character-image'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'ファイルがアップロードされていません' });
}
console.log(`キャラクター画像をアップロードしました: ${req.file.filename}`);
// キャラクターファイル情報を設定ファイルに保存
const settings = getSettings();
settings.characterImage = `/images/${req.file.filename}`;
const saved = saveSettings(settings);
if (!saved) {
throw new Error('設定の保存に失敗しました');
}
res.json({
success: true,
message: 'キャラクター画像が正常にアップロードされました',
imagePath: `/images/${req.file.filename}`
});
} catch (error) {
console.error('キャラクター画像アップロードエラー:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// キャラクター画像をデフォルトに戻すAPI
app.post('/api/settings/reset-character', express.json(), (req, res) => {
try {
// カスタム画像があれば削除
const customImagePath = path.join(__dirname, 'public', 'images', 'brave-character-custom.png');
const customJpgPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.jpg');
const customSvgPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.svg');
const customGifPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.gif');
if (fs.existsSync(customImagePath)) {
fs.unlinkSync(customImagePath);
console.log('カスタムキャラクター画像(PNG)を削除しました');
}
if (fs.existsSync(customJpgPath)) {
fs.unlinkSync(customJpgPath);
console.log('カスタムキャラクター画像(JPG)を削除しました');
}
if (fs.existsSync(customSvgPath)) {
fs.unlinkSync(customSvgPath);
console.log('カスタムキャラクター画像(SVG)を削除しました');
}
if (fs.existsSync(customGifPath)) {
fs.unlinkSync(customGifPath);
console.log('カスタムキャラクター画像(GIF)を削除しました');
}
// 設定からカスタム画像設定を削除
const settings = getSettings();
delete settings.characterImage;
saveSettings(settings);
res.json({
success: true,
message: 'キャラクター画像がデフォルトにリセットされました'
});
} catch (error) {
console.error('キャラクターリセットエラー:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// 設定情報取得API
// ... existing code ...
// 今日のイチ推しBraverを選出するメインの処理
async function resetAndSelectDailyPickupUser() {
try {
// 日本時間で今日の日付を取得
const today = getJSTDateString();
console.log(`今日(${today})のイチ推しbraverデータをリセットします`);
// 今日のピックアップデータを削除
await new Promise((resolve, reject) => {
db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
if (err) {
console.error('イチ推しbraverデータのリセットに失敗:', err);
reject(err);
return;
}
console.log(`今日のイチ推しbraverをリセットしました: ${this.changes}件のデータを削除`);
resolve();
});
});
// イチ推しBraverを選出
await selectDailyPickupUser();
console.log('イチ推しbraverの再選出が完了しました');
return true;
} catch (error) {
console.error('イチ推しbraver選出エラー:', error);
return false;
}
}
// selectDailyPickupUser関数
async function selectDailyPickupUser() {
console.log('イチ推しbraverを選出中...');
try {
// 今日の日付 (JST)
const today = getJSTDateString();
// テーブル構造を確認
console.log('daily_pickup_userテーブルの構造を確認しています...');
const tableInfo = await new Promise((resolve, reject) => {
db.all('PRAGMA table_info(daily_pickup_user)', (err, rows) => {
if (err) {
reject(err);
return;
}
resolve(rows);
});
});
// カラム名のリストを表示
const columns = tableInfo.map(col => col.name).join(', ');
console.log(`daily_pickup_userの既存カラム: ${columns}`);
// 本日のデータをリセット
console.log(`本日のイチ推しbraverデータをリセットします: ${today}`);
await new Promise((resolve, reject) => {
db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
if (err) {
console.error('イチ推しbraverデータのリセットに失敗:', err);
reject(err);
return;
}
console.log(`本日のイチ推しbraverデータをリセットしました: ${this.changes}件削除`);
resolve();
});
});
// 実際のデータを取得するSQLクエリ
const query = `
SELECT
u.user_id,
u.name,
u.real_name,
u.display_name,
u.avatar,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
(SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
) AS attendance_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
) AS office_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND length(text) >= 50
) AS report_count,
(
SELECT COUNT(*) FROM slack_reactions
WHERE user_id = u.user_id
) AS sent_reaction_count,
(
SELECT COUNT(*) FROM slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE m.user_id = u.user_id
) AS received_reaction_count
FROM
slack_users u
WHERE
u.is_bot = 0
AND (
SELECT COUNT(*) FROM slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE m.user_id = u.user_id
) >= 30
ORDER BY
RANDOM()
LIMIT 3
`;
console.log('リアクション数30以上のユーザーからランダムに3人選出するクエリを実行します');
// ユーザーを取得してイチ推しbraverに登録
const users = await new Promise((resolve, reject) => {
db.all(query, [], (err, rows) => {
if (err) {
console.error('ユーザー取得エラー:', err);
reject(err);
return;
}
resolve(rows);
});
});
console.log(`イチ推しbraver候補: ${users.length}人`);
// 各ユーザーをイチ推しbraverテーブルに登録
for (const row of users) {
try {
const userName = row.display_name || row.real_name || row.name || 'Unknown';
console.log(`イチ推しbraverに登録: ${userName} (${row.user_id}) - メッセージ数: ${row.message_count}, 返信数: ${row.reply_count}, リアクション数: ${row.received_reaction_count}`);
// アバターURLを高解像度版に変換
let avatarUrl = row.avatar;
if (avatarUrl && avatarUrl.includes('_72')) {
avatarUrl = avatarUrl.replace('_72', '_512');
console.log(`アバター画像URL変換: ${row.avatar} -> ${avatarUrl}`);
}
// AI分析テキストを生成
let analysis = '';
try {
// ユーザーの最新のメッセージを取得(最大10件)
const userMessages = await new Promise((resolve, reject) => {
db.all(
`SELECT text, timestamp FROM slack_messages WHERE user_id = ? ORDER BY timestamp DESC LIMIT 10`,
[row.user_id],
(err, messages) => {
if (err) {
console.error(`ユーザーメッセージ取得エラー (${row.user_id}):`, err);
reject(err);
} else {
resolve(messages);
}
}
);
});
// メッセージがあればAI分析を実行
if (userMessages && userMessages.length > 0) {
console.log(`${userName}のメッセージデータをAIで分析します (${userMessages.length}件)...`);
// メッセージテキストを結合
const messagesText = userMessages.map(m => m.text).join('\n\n');
// AI分析のプロンプト
const prompt = currentAiAnalysisPrompt + `\n\nユーザー名: ${userName}\nメッセージ数: ${row.message_count}件\n返信数: ${row.reply_count}件\n出勤数: ${row.attendance_count}日\nリアル出社: ${row.office_count}日\nスタンプ数: ${row.received_reaction_count}個\n\n【メッセージ一覧】\n${messagesText}\n\nこのユーザーの特徴を簡潔にまとめて、改行を最小限にし、段落間の空白行なしで350文字以内で分析してください。`;
try {
// Anthropic APIを呼び出し
if (anthropic && process.env.ANTHROPIC_API_KEY) {
const response = await anthropic.messages.create({
model: "claude-3-haiku-20240307",
max_tokens: 1000,
messages: [
{ role: "user", content: prompt }
],
temperature: 0.7,
});
// 改行や段落間の空白行を取り除いて分析テキストを整形
analysis = response.content[0].text
.replace(/\n\s*\n/g, '\n') // 空白行を削除
.replace(/\n+/g, ' ') // 残りの改行を空白に置換
.trim();
console.log(`AI分析完了 (${analysis.length}文字)`);
} else {
throw new Error('Anthropic APIが設定されていません');
}
} catch (aiError) {
// AI分析エラー時はフォールバック
console.error(`AI分析エラー (${row.user_id}):`, aiError);
const messageCount = row.message_count || 0;
const replyCount = row.reply_count || 0;
const attendanceCount = row.attendance_count || 0;
const officeCount = row.office_count || 0;
const receivedReactionCount = row.received_reaction_count || 0;
analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
}
} else {
// メッセージがない場合はデフォルトの分析
console.log(`${userName}のメッセージデータが見つかりません。デフォルトの分析を使用します。`);
const messageCount = row.message_count || 0;
const replyCount = row.reply_count || 0;
const attendanceCount = row.attendance_count || 0;
const officeCount = row.office_count || 0;
const receivedReactionCount = row.received_reaction_count || 0;
analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
}
} catch (analysisError) {
// エラー時はデフォルトの分析を使用
console.error(`分析処理エラー (${row.user_id}):`, analysisError);
const messageCount = row.message_count || 0;
const replyCount = row.reply_count || 0;
const attendanceCount = row.attendance_count || 0;
const officeCount = row.office_count || 0;
const receivedReactionCount = row.received_reaction_count || 0;
analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
}
// データを保存
await new Promise((resolve, reject) => {
const now = new Date();
const currentTimestamp = now.toISOString().replace('T', ' ').substring(0, 19);
db.run(
'INSERT INTO daily_pickup_user (user_id, name, real_name, display_name, avatar, message_count, reply_count, attendance_count, office_count, report_count, sent_reaction_count, received_reaction_count, analysis, pickup_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
row.user_id,
row.name,
row.real_name,
row.display_name,
avatarUrl,
row.message_count || 0,
row.reply_count || 0,
row.attendance_count || 0,
row.office_count || 0,
row.report_count || 0,
row.sent_reaction_count || 0,
row.received_reaction_count || 0,
analysis,
today,
currentTimestamp
],
(err) => {
if (err) {
console.error('イチ推しbraver登録エラー:', err);
reject(err);
} else {
resolve();
}
}
);
});
} catch (userError) {
console.error(`ユーザー登録エラー (${row.user_id}):`, userError);
}
}
console.log(`本日のイチ推しbraver選出完了: ${users.length}人`);
} catch (error) {
console.error('イチ推しbraver選出エラー:', error);
}
}
// Slackデータを同期するAPI
app.get('/api/sync-slack-data', async (req, res) => {
try {
if (!slackToken || !slackChannelIds.length) {
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
console.log('Slackデータ同期を開始...');
// 同期するチャンネルの指定(特定チャンネルまたは全チャンネル)
const requestedChannelId = req.query.channelId;
let channelsToSync = [];
if (requestedChannelId) {
// 特定のチャンネルが指定された場合
channelsToSync = [requestedChannelId];
console.log(`指定されたチャンネル ${requestedChannelId} のみを同期します`);
} else {
// すべてのチャンネルを同期
channelsToSync = global.slackChannelIds || slackChannelIds;
console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
}
// チャンネルごとの同期結果を保存する配列
const syncResults = [];
// 各チャンネルを順番に処理
for (const channelId of channelsToSync) {
console.log(`チャンネル ${channelId} の同期を開始します...`);
try {
// チャンネル情報を取得して保存
let channelName = 'Unknown Channel';
let channelInfo;
try {
channelInfo = await slackClient.conversations.info({
channel: channelId
});
channelName = channelInfo.channel.name || 'Unknown Channel';
console.log(`チャンネル情報取得: ${channelName} (${channelId})`);
// チャンネル情報をDBに保存
const channelStmt = db.prepare(`
INSERT OR REPLACE INTO slack_channels (channel_id, name)
VALUES (?, ?)
`);
channelStmt.run(channelId, channelName);
channelStmt.finalize();
console.log(`チャンネル情報を保存: ${channelName}`);
} catch (channelError) {
console.error(`チャンネル ${channelId} の情報取得エラー:`, channelError);
continue; // このチャンネルをスキップして次へ
}
// 過去3ヶ月のタイムスタンプを計算
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); // 3ヶ月前に変更
const oldestTimestamp = threeMonthsAgo.getTime() / 1000; // UnixタイムスタンプはUNIX時間(秒)
console.log(`3ヶ月前の日時: ${threeMonthsAgo.toLocaleString()}, タイムスタンプ: ${oldestTimestamp}`);
// DBに保存済みの最新タイムスタンプを取得
const latestMsg = await new Promise((resolve, reject) => {
db.get('SELECT MAX(timestamp) AS latest FROM slack_messages WHERE channel_id = ?', [channelId], (err, row) => {
if (err) {
console.error('最新メッセージ取得エラー:', err);
resolve(null);
} else {
resolve(row?.latest || null);
}
});
});
console.log(`既存の最新メッセージタイムスタンプ: ${latestMsg || 'なし'}`);
// 保存済みのメッセージIDを取得(重複チェック用)
const existingMessageIds = new Set();
await new Promise((resolve, reject) => {
db.each('SELECT message_id FROM slack_messages WHERE channel_id = ?', [channelId],
(err, row) => {
if (!err && row && row.message_id) {
existingMessageIds.add(row.message_id);
}
},
(err) => {
if (err) {
console.error('既存メッセージID取得エラー:', err);
} else {
console.log(`${existingMessageIds.size}件の既存メッセージIDを取得`);
}
resolve();
}
);
});
// 保存済みのスレッド返信IDを取得(重複チェック用)
const existingReplyIds = new Set();
await new Promise((resolve, reject) => {
db.each('SELECT message_id FROM slack_thread_replies WHERE channel_id = ?', [channelId],
(err, row) => {
if (!err && row && row.message_id) {
existingReplyIds.add(row.message_id);
}
},
(err) => {
if (err) {
console.error('既存スレッド返信ID取得エラー:', err);
} else {
console.log(`${existingReplyIds.size}件の既存スレッド返信IDを取得`);
}
resolve();
}
);
});
// メッセージ保存のプリペアドステートメント
const msgStmt = db.prepare(`
INSERT OR IGNORE INTO slack_messages (message_id, user_id, channel_id, text, timestamp)
VALUES (?, ?, ?, ?, ?)
`);
// スレッド返信保存のプリペアドステートメント
const replyStmt = db.prepare(`
INSERT OR IGNORE INTO slack_thread_replies (message_id, parent_message_id, user_id, channel_id, text, timestamp, reply_id, thread_ts, channel_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// 過去のメッセージ取得(初回は過去3ヶ月分、それ以降は差分のみ)
let cursor = null;
let totalMessages = 0;
let newMessages = 0;
let skippedMessages = 0;
let totalReplies = 0;
let newReplies = 0;
let skippedReplies = 0;
let hasMore = true;
let oldestMsgDate = null;
while (hasMore) {
try {
// チャンネル履歴を取得
const result = await slackClient.conversations.history({
channel: channelId,
cursor: cursor,
limit: 200, // 1回のリクエストで最大200件
oldest: Math.max(oldestTimestamp, latestMsg || 0), // 3ヶ月前か最新の保存済みメッセージの新しい方を基準にする
include_all_metadata: true // リアクション情報などすべてのメタデータを含める
});
// APIレスポンスのサンプルをログに出力(デバッグ用)
if (result.messages && result.messages.length > 0) {
console.log(`APIレスポンスサンプル: 最初のメッセージのフィールド一覧:`, Object.keys(result.messages[0]).join(', '));
// リアクションを含むメッセージがあるか確認
const msgWithReactions = result.messages.find(msg => msg.reactions && msg.reactions.length > 0);
if (msgWithReactions) {
console.log(`リアクションを含むメッセージ例:`, JSON.stringify(msgWithReactions.reactions, null, 2));
} else {
console.log(`リアクションを含むメッセージが見つかりませんでした`);
}
}
totalMessages += result.messages.length;
if (result.messages.length === 0) {
console.log('新しいメッセージはありません');
hasMore = false;
break;
}
// 最も古いメッセージの日時を記録(デバッグ用)
if (result.messages.length > 0) {
const oldestMsg = result.messages[result.messages.length - 1];
oldestMsgDate = new Date(parseFloat(oldestMsg.ts) * 1000).toLocaleString();
}
// DBにメッセージを保存
for (const msg of result.messages) {
if (msg.user && msg.ts && msg.text) {
// 既に保存済みのメッセージはスキップ
if (existingMessageIds.has(msg.ts)) {
skippedMessages++;
} else {
// 新しいメッセージをDBに保存
msgStmt.run(msg.ts, msg.user, channelId, msg.text, msg.ts);
newMessages++;
existingMessageIds.add(msg.ts); // 重複チェック用セットに追加
}
// スレッド情報のデバッグ
if (msg.thread_ts) {
console.log(`スレッド検出: message_id=${msg.ts}, thread_ts=${msg.thread_ts}, reply_count=${msg.reply_count || 0}`);
}
// スレッド返信がある場合、それらを取得して保存
if (msg.thread_ts && msg.reply_count && msg.reply_count > 0) {
try {
console.log(`スレッド返信を取得します: thread_ts=${msg.thread_ts}, reply_count=${msg.reply_count}`);
// このメッセージのスレッド返信を取得
const repliesResult = await slackClient.conversations.replies({
channel: channelId,
ts: msg.thread_ts,
limit: 200 // 一度に取得する最大数
});
console.log(`スレッド返信取得結果: ${repliesResult.messages.length}件`);
// 最初のメッセージ(親メッセージ)をスキップし、返信のみを処理
const replies = repliesResult.messages.slice(1);
totalReplies += replies.length;
// 各返信をDBに保存
for (const reply of replies) {
if (reply.user && reply.ts && reply.text) {
// 既に保存済みの返信はスキップ
if (existingReplyIds.has(reply.ts)) {
skippedReplies++;
} else {
// 新しい返信をDBに保存
replyStmt.run(reply.ts, msg.thread_ts, reply.user, channelId, reply.text, reply.ts, reply.reply_id, reply.thread_ts, reply.channel_name);
newReplies++;
existingReplyIds.add(reply.ts); // 重複チェック用セットに追加
}
}
}
} catch (replyError) {
console.error(`スレッド返信の取得エラー: thread_ts=${msg.thread_ts}`, replyError);
}
}
// reactions.get APIを使用してリアクション情報を取得
try {
console.log(`メッセージID: ${msg.ts} のリアクション情報を取得中...`);
// リアクション情報を直接取得
const reactionInfo = await slackClient.reactions.get({
channel: channelId,
timestamp: msg.ts,
full: true
});
// APIレスポンスの確認(デバッグ用)
console.log(`reactions.get APIレスポンス:`, Object.keys(reactionInfo).join(', '));
// リアクション情報があれば処理
if (reactionInfo.message && reactionInfo.message.reactions && reactionInfo.message.reactions.length > 0) {
console.log(`リアクション検出: message_id=${msg.ts}, reaction_count=${reactionInfo.message.reactions.length}`);
console.log(`リアクション詳細:`, JSON.stringify(reactionInfo.message.reactions, null, 2));
// リアクション情報をDBに保存するステートメント
const reactionStmt = db.prepare(`
INSERT OR IGNORE INTO slack_reactions (message_id, user_id, name, timestamp)
VALUES (?, ?, ?, ?)
`);
let totalSavedReactions = 0;
// 各リアクションを処理
for (const reaction of reactionInfo.message.reactions) {
if (reaction.name && reaction.users && reaction.users.length > 0) {
console.log(`リアクション「${reaction.name}」のユーザー数: ${reaction.users.length}`);
for (const userId of reaction.users) {
try {
// リアクションをDBに保存(インラインでSQLを実行)
db.run(`
INSERT OR IGNORE INTO slack_reactions (message_id, user_id, name, timestamp)
VALUES (?, ?, ?, ?)
`, [msg.ts, userId, reaction.name, msg.ts], function(err) {
if (err) {
console.error(`リアクション保存エラー: ${err.message}`);
} else {
console.log(`リアクション保存成功: message_id=${msg.ts}, user=${userId}, emoji=${reaction.name}`);
totalSavedReactions++;
}
});
} catch (insertError) {
console.error(`リアクション挿入エラー: ${insertError.message}`);
}
}
} else {
console.log(`警告: リアクション「${reaction.name}」にユーザー情報がありません`);
}
}
// 保存統計の出力
console.log(`メッセージID: ${msg.ts} について ${totalSavedReactions}件のリアクションを保存しました`);
// ステートメントをクローズ
// APIレート制限を避けるため少し待機
await new Promise(resolve => setTimeout(resolve, 200));
} else {
console.log(`メッセージID: ${msg.ts} にはリアクションがありません`);
if (reactionInfo.message) {
console.log(`メッセージフィールド:`, Object.keys(reactionInfo.message).join(', '));
} else {
console.log(`メッセージフィールドが存在しません`);
}
}
} catch (reactionError) {
console.error(`リアクション取得エラー (message_id=${msg.ts}):`, reactionError);
}
}
}
// 次のページがあるか確認
cursor = result.response_metadata?.next_cursor;
hasMore = Boolean(cursor);
console.log(`次のカーソル: ${cursor || 'なし'}`);
} catch (historyError) {
console.error(`チャンネル履歴の取得エラー: ${channelId}`, historyError);
hasMore = false;
}
}
// プリペアドステートメントをクローズ
msgStmt.finalize();
replyStmt.finalize();
console.log(`チャンネル ${channelName} (${channelId}) の同期が完了しました。メッセージ: ${newMessages}件(スキップ: ${skippedMessages}件)、返信: ${newReplies}件(スキップ: ${skippedReplies}件)`);
// チャンネルごとの結果を保存
syncResults.push({
channelId,
channelName,
newMessages,
skippedMessages,
newReplies,
skippedReplies,
totalMessages,
totalReplies,
oldestMsgDate
});
} catch (channelSyncError) {
console.error(`チャンネル ${channelId} の同期エラー:`, channelSyncError);
syncResults.push({
channelId,
error: channelSyncError.message
});
}
}
// イチ推しbraverのリセットと再選出
try {
await resetAndSelectDailyPickupUser();
} catch (pickupError) {
console.error('同期後のイチ推しbraver選出エラー:', pickupError);
}
return res.json({
success: true,
syncedChannels: syncResults,
channelCount: syncResults.length,
period: `${getJSTDate().toLocaleDateString('ja-JP')} までの3ヶ月分`,
message: "スタンプ情報も含めて全データを同期しました"
});
} catch (error) {
console.error('Slackデータ同期エラー:', error);
return res.status(500).json({ error: `同期処理中にエラーが発生しました: ${error.message}` });
}
});
// Slackユーザー一覧を取得するAPI
app.get('/api/users', async (req, res) => {
try {
const query = `
SELECT
u.user_id,
u.name,
u.real_name,
u.display_name,
u.avatar,
u.is_bot,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
(SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
) AS attendance_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
) AS office_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND length(text) >= 50
) AS weekly_report_count,
(
SELECT COUNT(*) FROM slack_reactions
WHERE user_id = u.user_id
) AS sent_reaction_count,
(
SELECT COUNT(*) FROM slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE m.user_id = u.user_id
) AS received_reaction_count
FROM
slack_users u
WHERE
u.is_bot = 0
ORDER BY
message_count DESC
`;
db.all(query, [], (err, users) => {
if (err) {
console.error('ユーザー一覧取得エラー:', err);
return res.status(500).json({ error: 'ユーザーデータの取得に失敗しました' });
}
// リアル出社率を計算
users.forEach(user => {
const officeCount = user.office_count || 0;
const attendanceCount = user.attendance_count || 0;
user.office_ratio = attendanceCount > 0 ? Math.round((officeCount / attendanceCount) * 100) : 0;
});
res.json(users);
});
} catch (error) {
console.error('ユーザー一覧取得エラー:', error);
res.status(500).json({ error: 'ユーザーデータの取得に失敗しました' });
}
});
// ユーザー詳細情報を取得する代替API
app.get('/api/user-detail/:userId', (req, res) => {
try {
const userId = req.params.userId;
console.log(`ユーザー詳細情報リクエスト: ${userId}`);
// 基本的なユーザー情報を取得するシンプルなクエリ
const query = `
SELECT
user_id,
name,
real_name,
display_name,
avatar,
is_bot
FROM
slack_users
WHERE
user_id = ?
`;
db.get(query, [userId], (err, user) => {
if (err) {
console.error('ユーザー詳細情報取得エラー:', err);
return res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
}
if (!user) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
// 追加情報を取得
const countQuery = `
SELECT
(SELECT COUNT(*) FROM slack_messages WHERE user_id = ?) AS message_count,
(SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = ?) AS reply_count,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')) AS attendance_count,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')) AS office_count,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND LENGTH(text) >= 50) AS weekly_report_count,
(SELECT COUNT(*) FROM slack_reactions WHERE user_id = ?) AS sent_reaction_count,
(SELECT COUNT(*) FROM slack_reactions r JOIN slack_messages m ON r.message_id = m.message_id WHERE m.user_id = ?) AS received_reaction_count
`;
db.get(countQuery, [userId, userId, userId, userId, userId, userId, userId], (countErr, counts) => {
if (countErr) {
console.error('ユーザーカウント情報取得エラー:', countErr);
// エラーがあっても基本情報は返す
} else if (counts) {
// カウント情報をユーザー情報に追加
Object.assign(user, counts);
}
// 週報・発言リストを取得(50文字以上のメッセージ)
const reportsQuery = `
SELECT
m.message_id,
m.text as text_preview,
m.timestamp as date,
c.name as channel_name,
m.channel_id
FROM
slack_messages m
LEFT JOIN slack_channels c ON m.channel_id = c.channel_id
WHERE
m.user_id = ?
AND LENGTH(m.text) >= 50
ORDER BY
m.timestamp DESC
LIMIT 20
`;
db.all(reportsQuery, [userId], (reportsErr, reports) => {
if (reportsErr) {
console.error('週報・発言リスト取得エラー:', reportsErr);
user.reports = [];
} else {
// 日付をフォーマット
reports.forEach(report => {
if (report.date) {
report.date = new Date(parseFloat(report.date) * 1000);
}
// テキストを制限(長すぎる場合)
if (report.text_preview && report.text_preview.length > 200) {
report.text_preview = report.text_preview.substring(0, 200) + '...';
}
});
user.reports = reports || [];
}
// 返信リストを取得
const repliesQuery = `
SELECT
r.message_id,
r.text as text_preview,
r.timestamp as date,
r.parent_message_id,
c.name as channel_name,
r.channel_id,
(SELECT text FROM slack_messages WHERE message_id = r.parent_message_id) as parent_preview
FROM
slack_thread_replies r
LEFT JOIN slack_channels c ON r.channel_id = c.channel_id
WHERE
r.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
db.all(repliesQuery, [userId], (repliesErr, replies) => {
if (repliesErr) {
console.error('返信リスト取得エラー:', repliesErr);
user.replies = [];
} else {
// 日付をフォーマット
replies.forEach(reply => {
if (reply.date) {
reply.date = new Date(parseFloat(reply.date) * 1000);
}
// テキストを制限(長すぎる場合)
if (reply.text_preview && reply.text_preview.length > 200) {
reply.text_preview = reply.text_preview.substring(0, 200) + '...';
}
if (reply.parent_preview && reply.parent_preview.length > 100) {
reply.parent_preview = reply.parent_preview.substring(0, 100) + '...';
}
});
user.replies = replies || [];
}
// 送信したスタンプを取得
const sentStampsQuery = `
SELECT
r.name as reaction,
r.timestamp as date,
m.text as message_preview,
r.message_id,
(SELECT u.display_name || COALESCE(' (' || u.real_name || ')', '') FROM slack_users u JOIN slack_messages msg ON u.user_id = msg.user_id WHERE msg.message_id = r.message_id) as target_user
FROM
slack_reactions r
LEFT JOIN slack_messages m ON r.message_id = m.message_id
WHERE
r.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
db.all(sentStampsQuery, [userId], (sentStampsErr, sentStamps) => {
if (sentStampsErr) {
console.error('送信スタンプリスト取得エラー:', sentStampsErr);
user.sent_stamps = [];
} else {
// 日付をフォーマット
sentStamps.forEach(stamp => {
if (stamp.date) {
stamp.date = new Date(parseFloat(stamp.date) * 1000);
}
// テキストを制限(長すぎる場合)
if (stamp.message_preview && stamp.message_preview.length > 150) {
stamp.message_preview = stamp.message_preview.substring(0, 150) + '...';
}
});
user.sent_stamps = sentStamps || [];
}
// 受信したスタンプを取得
const receivedStampsQuery = `
SELECT
r.name as reaction,
r.timestamp as date,
m.text as message_preview,
r.message_id,
(SELECT u.display_name || COALESCE(' (' || u.real_name || ')', '') FROM slack_users u WHERE u.user_id = r.user_id) as from_user
FROM
slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE
m.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
db.all(receivedStampsQuery, [userId], (receivedStampsErr, receivedStamps) => {
if (receivedStampsErr) {
console.error('受信スタンプリスト取得エラー:', receivedStampsErr);
user.received_stamps = [];
} else {
// 日付をフォーマット
receivedStamps.forEach(stamp => {
if (stamp.date) {
stamp.date = new Date(parseFloat(stamp.date) * 1000);
}
// テキストを制限(長すぎる場合)
if (stamp.message_preview && stamp.message_preview.length > 150) {
stamp.message_preview = stamp.message_preview.substring(0, 150) + '...';
}
});
user.received_stamps = receivedStamps || [];
}
// AI分析結果は削除
user.analysis = '';
// 成功レスポンス
res.json(user);
});
});
});
});
});
});
} catch (error) {
console.error('ユーザー詳細取得エラー:', error);
res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
}
});
// 特定ユーザーのメッセージを分析するAPI
app.get('/api/analyze-user/:userId', async (req, res) => {
try {
const userId = req.params.userId;
const isPositive = req.query.positive === 'true';
console.log(`ユーザー分析リクエスト: ${userId}, ポジティブ分析: ${isPositive}`);
// ユーザー名を取得
const userQuery = `SELECT name, real_name, display_name FROM slack_users WHERE user_id = ?`;
db.get(userQuery, [userId], async (err, user) => {
if (err) {
console.error('ユーザー情報取得エラー:', err);
return res.status(500).json({ error: 'ユーザー情報の取得に失敗しました' });
}
if (!user) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
const userName = user.display_name || user.real_name || user.name || userId;
// ユーザーのメッセージを取得(最大100件)
const messageQuery = `
SELECT text, timestamp FROM slack_messages
WHERE user_id = ?
ORDER BY timestamp DESC
LIMIT 100
`;
db.all(messageQuery, [userId], async (err, messages) => {
if (err) {
console.error('メッセージ取得エラー:', err);
return res.status(500).json({ error: 'メッセージの取得に失敗しました' });
}
// メッセージが少なすぎる場合
if (messages.length < 3) {
return res.json({
success: true,
source: "simple",
analysis: {
userName: userName,
summary: `${userName}さんの分析に必要なメッセージが不足しています。もう少しSlackでの会話データが必要です。`,
communicationStyle: "データ不足",
strengths: ["データが不足しています"],
interests: ["データが不足しています"],
workStyle: "データが不足しています",
tags: ["データ不足"]
}
});
}
try {
// OpenAI APIを使って分析
const messagesText = messages.map(m => m.text).join('\n\n');
const prompt = currentAiAnalysisPrompt + `
ユーザー名: ${userName}
メッセージ数: ${messages.length}件
【メッセージ一覧】
${messagesText}
分析対象のユーザー名は「${userName}」です。このユーザーの特徴を600文字程度で具体的に分析してください。`;
// OpenAI APIを呼び出し
const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: currentAiAnalysisPrompt
},
{
role: "user",
content: prompt
}
],
temperature: 0.6,
max_tokens: 500
})
});
if (!openaiResponse.ok) {
console.error('OpenAI APIエラー:', await openaiResponse.text());
throw new Error('OpenAI APIからの応答に問題がありました');
}
const openaiData = await openaiResponse.json();
const analysisText = openaiData.choices[0].message.content.trim();
// クリーンな分析結果を返す
const sanitizedAnalysis = sanitizeApiResponse(analysisText);
// 分析結果をJSON形式で返す
return res.json({
success: true,
source: "openai",
analysis: {
userName: userName,
summary: sanitizedAnalysis.length > 400 ? sanitizedAnalysis.substring(0, 400) + "..." : sanitizedAnalysis,
communicationStyle: "分析完了",
strengths: ["OpenAIによる分析を表示中"],
interests: ["OpenAIによる分析を表示中"],
workStyle: "OpenAIによる分析を表示中",
tags: ["AI分析", "ポジティブ評価"]
}
});
} catch (error) {
console.error('OpenAI API呼び出しエラー:', error);
// エラー時は簡易分析を返す
return res.json({
success: true,
source: "error_fallback",
error: error.message,
analysis: {
userName: userName,
summary: `${userName}さんの分析中にエラーが発生しました。時間をおいて再度お試しください。エラー: ${error.message}`,
communicationStyle: "エラー発生",
strengths: ["データ分析エラー"],
interests: ["データ分析エラー"],
workStyle: "データ分析エラー",
tags: ["エラー"]
}
});
}
});
});
} catch (error) {
console.error('ユーザー分析中にエラーが発生しました:', error);
res.status(500).json({ error: error.message });
}
});
// グラフ表示用のTOP30ユーザーを取得するAPI
app.get('/api/stats/top-users', async (req, res) => {
try {
const type = req.query.type || req.query.chart || 'messages';
const period = req.query.period || '3months'; // 新しいパラメータ: 期間(3months, 2weeks, 3days)
let column = 'message_count';
let title = 'メッセージ投稿数';
// 期間に応じた日付条件を設定 (日本時間ベース)
let dateCondition = '';
const now = getJSTDate(); // 日本時間のnowを使用
if (period === '3months') {
// 過去3ヶ月
const threeMonthsAgo = new Date(now);
threeMonthsAgo.setMonth(now.getMonth() - 3);
dateCondition = `AND timestamp > '${threeMonthsAgo.getTime() / 1000}'`;
title += '(直近3ヶ月)';
} else if (period === '2weeks') {
// 過去1ヶ月(期間名は変更していないが内容を変更)
const oneMonthAgo = new Date(now);
oneMonthAgo.setMonth(now.getMonth() - 1);
dateCondition = `AND timestamp > '${oneMonthAgo.getTime() / 1000}'`;
title += '(直近1ヶ月)';
} else if (period === '3days') {
// 過去1週間(期間名は変更していないが内容を変更)
const oneWeekAgo = new Date(now);
oneWeekAgo.setDate(now.getDate() - 7);
dateCondition = `AND timestamp > '${oneWeekAgo.getTime() / 1000}'`;
title += '(直近1週間)';
}
// タイプに応じて取得するカラムを変更
switch (type) {
case 'messages':
column = `(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
${dateCondition}
)`;
title = 'メッセージ投稿数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'replies':
column = `(
SELECT COUNT(*) FROM slack_thread_replies
WHERE user_id = u.user_id
${dateCondition}
)`;
title = 'スレッド返信数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'received_reactions':
column = `(
SELECT COUNT(*) FROM slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE m.user_id = u.user_id
${dateCondition.replace('timestamp', 'm.timestamp')}
)`;
title = 'スタンプを貰った数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'sent_reactions':
column = `(
SELECT COUNT(*) FROM slack_reactions r
WHERE r.user_id = u.user_id
${dateCondition.replace('timestamp', 'r.timestamp')}
)`;
title = 'スタンプを送った数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'attendance':
column = `(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (
text LIKE '/yolo%' OR
text LIKE '/Yolo%'
)
${dateCondition}
)`;
title = '出勤数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'office':
column = `(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (
text LIKE '/yolo_office%' OR
text LIKE '/Yolo_office%'
)
${dateCondition}
)`;
title = 'リアル出社数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
case 'reports':
column = `(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND length(text) >= 50
${dateCondition}
)`;
title = '発信数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
break;
default:
column = `(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id ${dateCondition})`;
title = 'メッセージ投稿数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
}
// SQL実行
const query = `
SELECT
u.user_id,
u.name,
u.real_name,
u.display_name,
u.avatar,
${column} count_value
FROM
slack_users u
WHERE
u.is_bot = 0
AND ${column} > 0
ORDER BY
count_value DESC
LIMIT 30
`;
db.all(query, [], (err, users) => {
if (err) {
console.error('グラフデータ取得エラー:', err);
return res.status(500).json({ error: 'グラフデータの取得に失敗しました', success: false });
}
// 表示名を設定
users.forEach(user => {
user.displayName = user.display_name || user.real_name || user.name || 'Unknown';
});
res.json({
success: true,
title: title,
data: users,
users: users // clientが users フィールドを期待している場合に対応
});
});
} catch (error) {
console.error('グラフデータ取得エラー:', error);
res.status(500).json({ error: 'グラフデータの取得に失敗しました', success: false });
}
});
// サーバー起動
app.listen(port, () => {
console.log(`サーバーが起動しました: http://localhost:${port}`);
console.log(`テストページ: http://localhost:${port}/simple`);
// サーバー起動時にも自動同期チェックを実行
setTimeout(async () => {
console.log('サーバー起動後の自動同期チェックを実行します...');
try {
await checkAndSyncIfNeeded();
console.log('サーバー起動後の自動同期チェック完了');
} catch (error) {
console.error('サーバー起動後の自動同期チェックでエラーが発生:', error);
}
}, 3000); // サーバー起動から3秒後に実行
});
// データベースを全削除するAPI
app.post('/api/reset-database', (req, res) => {
try {
console.log('データベースの全削除を開始します...');
// トランザクションを開始
db.serialize(() => {
// 各テーブルを空にする
db.run('DELETE FROM slack_messages', function(err) {
if (err) {
console.error('slack_messagesテーブル削除エラー:', err);
} else {
console.log(`slack_messagesから${this.changes}件のデータを削除しました`);
}
});
db.run('DELETE FROM slack_thread_replies', function(err) {
if (err) {
console.error('slack_thread_repliesテーブル削除エラー:', err);
} else {
console.log(`slack_thread_repliesから${this.changes}件のデータを削除しました`);
}
});
db.run('DELETE FROM slack_reactions', function(err) {
if (err) {
console.error('slack_reactionsテーブル削除エラー:', err);
} else {
console.log(`slack_reactionsから${this.changes}件のデータを削除しました`);
}
});
db.run('DELETE FROM slack_users', function(err) {
if (err) {
console.error('slack_usersテーブル削除エラー:', err);
} else {
console.log(`slack_usersから${this.changes}件のデータを削除しました`);
}
});
db.run('DELETE FROM slack_channels', function(err) {
if (err) {
console.error('slack_channelsテーブル削除エラー:', err);
} else {
console.log(`slack_channelsから${this.changes}件のデータを削除しました`);
}
});
db.run('DELETE FROM user_analysis', function(err) {
if (err) {
console.error('user_analysisテーブル削除エラー:', err);
} else {
console.log(`user_analysisから${this.changes}件のデータを削除しました`);
}
});
});
console.log('データベースの全削除が完了しました');
res.json({ success: true, message: 'データベースを全削除しました' });
} catch (error) {
console.error('データベース削除エラー:', error);
res.status(500).json({ error: 'データベースの削除に失敗しました' });
}
});
// 最終同期日時を特定の日付に設定するAPI(テスト用)
app.post('/api/set-last-sync-date', express.json(), (req, res) => {
try {
// リクエストから日時情報を取得
const { date } = req.body;
if (!date) {
// デフォルトで2024年5月9日14:00に設定
const defaultDate = new Date('2024-05-09T14:00:00+09:00');
const defaultTimestamp = Math.floor(defaultDate.getTime() / 1000);
// データベース更新
db.run('DELETE FROM last_sync_date', function(deleteErr) {
if (deleteErr) {
console.error('最終同期日時の削除エラー:', deleteErr);
return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
}
const dateString = defaultDate.toISOString().split('T')[0];
db.run(
'INSERT INTO last_sync_date (last_sync_date, last_sync_timestamp) VALUES (?, ?)',
[dateString, defaultTimestamp],
function(insertErr) {
if (insertErr) {
console.error('最終同期日時の挿入エラー:', insertErr);
return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
}
console.log(`最終同期日時を設定しました: ${dateString} (${defaultTimestamp})`);
return res.json({
success: true,
message: `最終同期日時を ${dateString} に設定しました`,
date: dateString,
timestamp: defaultTimestamp
});
}
);
});
} else {
// 指定された日時を使用
const specifiedDate = new Date(date);
const timestamp = Math.floor(specifiedDate.getTime() / 1000);
// データベース更新
db.run('DELETE FROM last_sync_date', function(deleteErr) {
if (deleteErr) {
console.error('最終同期日時の削除エラー:', deleteErr);
return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
}
const dateString = specifiedDate.toISOString().split('T')[0];
db.run(
'INSERT INTO last_sync_date (last_sync_date, last_sync_timestamp) VALUES (?, ?)',
[dateString, timestamp],
function(insertErr) {
if (insertErr) {
console.error('最終同期日時の挿入エラー:', insertErr);
return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
}
console.log(`最終同期日時を設定しました: ${dateString} (${timestamp})`);
return res.json({
success: true,
message: `最終同期日時を ${dateString} に設定しました`,
date: dateString,
timestamp: timestamp
});
}
);
});
}
} catch (error) {
console.error('最終同期日時設定エラー:', error);
res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
}
});
// グローバル関数として追加:JSON解析エラー診断と修正
function fixJsonParseError(jsonString, errorMessage) {
console.log('JSON解析エラー修正を試みます...');
// jsonStringがundefinedやnullでないことを確認
if (!jsonString) {
console.error('修正対象のjsonStringが未定義です');
return '';
}
console.log(`元のエラー: ${errorMessage}`);
console.log(`JSON文字列長: ${jsonString.length}文字`);
// エラーメッセージの詳細をさらに表示
if (errorMessage.includes('Unexpected non-whitespace character after JSON at position 67')) {
console.log('===== 位置67エラーの詳細診断 =====');
console.log(`エラー位置周辺(55-80)の文字列: "${jsonString.substring(55, 80)}"`);
// 各文字のコードポイントを表示
console.log('文字ごとのコードポイント:');
for (let i = 60; i < 75; i++) {
if (i < jsonString.length) {
const char = jsonString.charAt(i);
console.log(`位置${i}: "${char}" (コード: ${char.charCodeAt(0)}, 16進数: 0x${char.charCodeAt(0).toString(16)})`);
}
}
// 16進数でダンプ
console.log('16進数ダンプ (位置60-75):');
let hexDump = '';
for (let i = 60; i < 75; i++) {
if (i < jsonString.length) {
hexDump += jsonString.charCodeAt(i).toString(16).padStart(2, '0') + ' ';
}
}
console.log(hexDump);
}
// 特定のエラーメッセージに対する特殊処理
if (errorMessage.includes('Unexpected non-whitespace character after JSON at position 67') ||
(errorMessage.includes('Unexpected token') && errorMessage.includes('position 67'))) {
console.log('特定の位置67エラーを検出しました - 専用の修正関数を呼び出します');
const fixedString = fixPosition67Error(jsonString);
// 修正前後の比較ログ
if (fixedString !== jsonString) {
console.log('修正が適用されました:');
console.log(`修正前(位置60-75): "${jsonString.substring(60, 75)}"`);
console.log(`修正後(位置60-75): "${fixedString.substring(60, 75)}"`);
// 修正が正常にパースできるか確認
try {
JSON.parse(fixedString);
console.log('修正後のJSONは正常にパースできました!');
} catch (e) {
console.log(`修正後もパースできません: ${e.message}`);
}
} else {
console.log('専用関数による修正は適用されませんでした');
}
return fixedString;
}
// 共通の問題:位置を特定
const positionMatch = errorMessage.match(/position\s+(\d+)/i);
if (!positionMatch) {
console.log('エラー位置を特定できませんでした');
return jsonString; // 修正できない場合は元の文字列を返す
}
const errorPos = parseInt(positionMatch[1]);
console.log(`エラー位置: ${errorPos}`);
// エラー周辺のコンテキストを表示
const contextStart = Math.max(0, errorPos - 15);
const contextEnd = Math.min(jsonString.length, errorPos + 15);
console.log(`エラー周辺: "${jsonString.substring(contextStart, contextEnd)}"`);
// エラー位置の文字とその前後
const errorChar = jsonString.charAt(errorPos);
const beforeChar = errorPos > 0 ? jsonString.charAt(errorPos - 1) : '';
const afterChar = errorPos < jsonString.length - 1 ? jsonString.charAt(errorPos + 1) : '';
console.log(`問題の文字: "${beforeChar}[${errorChar}]${afterChar}" (コード: ${errorChar.charCodeAt(0)})`);
// 特定のケース:位置67問題(非常に一般的なエラー)
if (errorPos >= 66 && errorPos <= 68) {
console.log('位置67周辺の一般的な問題を検出');
// 位置67の文字を削除してみる(最も効果的な修正方法)
let fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
try {
JSON.parse(fixedJson);
console.log('位置67の文字を削除することで修正に成功しました');
return fixedJson;
} catch (e) {
console.log('位置67の文字削除では修正できませんでした');
}
}
// ケース1:無効な文字(制御文字など)
if (!/[\x20-\x7E]/.test(errorChar)) {
console.log('無効な文字を検出しました');
const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
try {
JSON.parse(fixedJson);
console.log('無効な文字を削除することで修正に成功しました');
return fixedJson;
} catch (e) {
console.log('無効な文字の削除では修正できませんでした');
}
}
// ケース2:余分なカンマ
if (errorChar === ',' && (afterChar === '}' || afterChar === ']' || afterChar === ',')) {
console.log('余分なカンマを検出しました');
const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
try {
JSON.parse(fixedJson);
console.log('余分なカンマを削除することで修正に成功しました');
return fixedJson;
} catch (e) {
console.log('余分なカンマの削除では修正できませんでした');
}
}
// 最後の手段:エラー位置の文字を単純に削除
if (errorPos < jsonString.length) {
const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
try {
JSON.parse(fixedJson);
console.log('エラー位置の文字を削除することで修正に成功しました');
return fixedJson;
} catch (e) {
console.log('エラー位置の文字削除では修正できませんでした');
}
}
// どの方法でも修正できなかった場合
console.log('自動修正は失敗しました。元のJSONを返します。');
return jsonString;
}
// 67文字目付近での特定のJSON解析エラーを修正する特殊関数
function fixPosition67Error(jsonString) {
console.log('特定の67文字目エラー修正を試みます...');
// jsonStringがundefinedやnullでないことを確認
if (!jsonString) {
console.error('修正対象のjsonStringが未定義です');
return '';
}
// より詳細なデバッグ情報を追加
if (jsonString.length < 67) {
console.log('文字列が短すぎるため、該当エラーではありません(長さ: ' + jsonString.length + ')');
return jsonString;
}
// 位置67前後の文字を詳細にログ出力
console.log(`位置67周辺(60-75): "${jsonString.substring(60, 75)}"`);
// バイナリダンプ形式で位置67付近の文字コードを出力
let binaryDump = '位置67周辺のバイナリダンプ:\n';
for (let i = 65; i <= 70; i++) {
if (i < jsonString.length) {
const char = jsonString.charAt(i);
const code = jsonString.charCodeAt(i);
binaryDump += `位置${i}: "${char}" (ASCII: ${code}, HEX: 0x${code.toString(16)})\n`;
}
}
console.log(binaryDump);
// 位置67の文字の詳細診断
if (jsonString.length > 67) {
const char67 = jsonString.charAt(67);
console.log(`位置67の文字: "${char67}" (コード: ${char67.charCodeAt(0)}, 16進数: 0x${char67.charCodeAt(0).toString(16)})`);
// 不可視文字の詳細診断
if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67)) {
console.log('位置67に不可視文字または非ASCII文字を検出しました');
if (char67 === '\u200B') console.log('→ ゼロ幅スペース (U+200B)');
else if (char67 === '\u200C') console.log('→ ゼロ幅非接合子 (U+200C)');
else if (char67 === '\u200D') console.log('→ ゼロ幅接合子 (U+200D)');
else if (char67 === '\uFEFF') console.log('→ BOM (U+FEFF)');
else console.log('→ その他の不可視/非ASCII文字');
}
// 前後の文脈も確認して、JSON構造上の問題を診断
const before = jsonString.substring(Math.max(0, 67 - 10), 67);
const after = jsonString.substring(68, Math.min(jsonString.length, 68 + 10));
console.log(`位置67の前10文字: "${before}"`);
console.log(`位置67の後10文字: "${after}"`);
// JSON文字列内かオブジェクトの境界かを判定
let isInString = false;
let quotePos = -1;
for (let i = 0; i < 67; i++) {
if (jsonString[i] === '"' && (i === 0 || jsonString[i-1] !== '\\')) {
isInString = !isInString;
quotePos = i;
}
}
if (isInString) {
console.log(`位置67はJSON文字列内にあります(最後の引用符位置: ${quotePos})`);
} else {
console.log('位置67はJSON文字列の外にあります');
}
}
// 修正戦略を複数適用し、成功したらすぐに返す
// 修正戦略1: 位置67周辺の文字を直接削除
console.log('修正戦略1: 位置67の文字を直接削除');
if (jsonString.length > 67) {
const forcedRemoval = jsonString.substring(0, 67) + jsonString.substring(68);
try {
JSON.parse(forcedRemoval);
console.log('修正戦略1が成功しました!');
return forcedRemoval;
} catch (e) {
console.log(`修正戦略1が失敗: ${e.message}`);
}
}
// 修正戦略2: 位置67の前後の文字も含めて削除(5文字ほど)
console.log('修正戦略2: 位置67周辺(65-70)の文字を削除');
if (jsonString.length > 70) {
const widerRemoval = jsonString.substring(0, 65) + jsonString.substring(70);
try {
JSON.parse(widerRemoval);
console.log('修正戦略2が成功しました!');
return widerRemoval;
} catch (e) {
console.log(`修正戦略2が失敗: ${e.message}`);
}
}
// 修正戦略3: もし引用符の問題なら、文字列全体を再構成
console.log('修正戦略3: JSONの文字列構造を修正');
try {
// 文字列内の引用符を確認
let processedJson = '';
let inString = false;
let escapeNext = false;
let lastQuotePos = -1;
for (let i = 0; i < jsonString.length; i++) {
const char = jsonString.charAt(i);
if (escapeNext) {
processedJson += char;
escapeNext = false;
continue;
}
if (char === '\\') {
processedJson += char;
escapeNext = true;
continue;
}
if (char === '"') {
if (!inString) {
lastQuotePos = i;
inString = true;
} else {
// 文字列終了
inString = false;
}
}
// 位置67付近の問題を避ける特殊処理
if (i === 67) {
if (inString && lastQuotePos === 66) {
// もし位置67が文字列の最初の文字なら、削除しない
processedJson += char;
} else if (inString) {
// 文字列内の場合は特に注意して処理
if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char) || char === '\\') {
console.log(`位置${i}の文字「${char}」を削除します(文字列内)`);
continue; // この文字をスキップ
} else {
processedJson += char;
}
} else {
// 文字列外の場合、位置67の文字は削除を試みる
console.log(`位置${i}の文字「${char}」を削除します(文字列外)`);
continue; // この文字をスキップ
}
} else {
processedJson += char;
}
}
try {
JSON.parse(processedJson);
console.log('修正戦略3が成功しました!');
return processedJson;
} catch (e) {
console.log(`修正戦略3が失敗: ${e.message}`);
}
} catch (e) {
console.log(`修正戦略3の実行中にエラー: ${e.message}`);
}
// 修正戦略4: JSON本体の抽出({から始まる最初の完全なJSONオブジェクト)
console.log('修正戦略4: 完全なJSONオブジェクトを抽出');
try {
// JSONのフォーマットを厳密にチェック
let depth = 0;
let inJSONString = false;
let escapeChar = false;
let validStart = -1;
let validEnd = -1;
for (let i = 0; i < jsonString.length; i++) {
const char = jsonString.charAt(i);
if (escapeChar) {
escapeChar = false;
continue;
}
if (inJSONString && char === '\\') {
escapeChar = true;
continue;
}
if (char === '"' && (i === 0 || jsonString.charAt(i-1) !== '\\')) {
inJSONString = !inJSONString;
continue;
}
if (inJSONString) {
continue; // 文字列内の文字は無視
}
if (char === '{') {
if (depth === 0) {
validStart = i;
}
depth++;
} else if (char === '}') {
depth--;
if (depth === 0 && validStart >= 0) {
validEnd = i;
break; // 完全なJSONオブジェクトを見つけた
}
}
}
if (validStart >= 0 && validEnd > validStart) {
const extractedJson = jsonString.substring(validStart, validEnd + 1);
try {
JSON.parse(extractedJson);
console.log('修正戦略4が成功しました!');
return extractedJson;
} catch (e) {
console.log(`修正戦略4が失敗: ${e.message}`);
}
} else {
console.log('有効なJSONオブジェクトを見つけられませんでした');
}
} catch (e) {
console.log(`修正戦略4の実行中にエラー: ${e.message}`);
}
// 修正戦略5: すべての制御文字や問題文字を一括削除
console.log('修正戦略5: 非表示文字および制御文字を一括削除');
try {
let sanitizedJson = jsonString
.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF\u2028\u2029]/g, '')
.replace(/\\\\/g, '\\') // 二重バックスラッシュの修正
.replace(/,\s*}/g, '}') // 末尾のカンマの修正
.replace(/,\s*]/g, ']') // 配列末尾のカンマの修正
.replace(/\\(?!["\\/bfnrt])/g, ''); // 無効なエスケープシーケンスの修正
// 特に位置67付近を重点的に確認
if (sanitizedJson.length >= 67) {
const char67 = sanitizedJson.charAt(67);
if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67) || char67 === '\\') {
sanitizedJson = sanitizedJson.substring(0, 67) + sanitizedJson.substring(68);
}
}
try {
JSON.parse(sanitizedJson);
console.log('修正戦略5が成功しました!');
return sanitizedJson;
} catch (e) {
console.log(`修正戦略5が失敗: ${e.message}`);
}
} catch (e) {
console.log(`修正戦略5の実行中にエラー: ${e.message}`);
}
// 修正戦略6: 完全なクリーニングを実行
try {
console.log('修正戦略6: 完全なクリーニングを実行');
const fullyCleaned = cleanupAllProblematicCharacters(jsonString);
if (fullyCleaned !== jsonString) {
try {
JSON.parse(fullyCleaned);
console.log('修正戦略6(完全クリーニング)が成功しました!');
return fullyCleaned;
} catch (e) {
console.log(`修正戦略6が失敗: ${e.message}`);
}
} else {
console.log('完全クリーニングでの変更はありませんでした');
}
} catch (e) {
console.log(`修正戦略6の実行中にエラー: ${e.message}`);
}
console.log('すべての修正戦略が失敗しました。元の文字列を返します。');
return jsonString; // 修正できなかった場合は元の文字列を返す
}
// 新しい修正関数を追加: すべての不可視文字と問題のある文字を削除
function cleanupAllProblematicCharacters(jsonString) {
console.log('すべての不可視文字と問題のある文字をクリーニングします...');
// jsonStringがundefinedやnullでないことを確認
if (!jsonString) {
console.error('クリーニング対象のjsonStringが未定義です');
return '';
}
// オリジナルの長さを記録
const originalLength = jsonString.length;
// 前後の余分なスペースを削除
let cleaned = jsonString.trim();
// 位置67付近の詳細診断
if (cleaned.length >= 67) {
const char67 = cleaned.charAt(67);
console.log(`クリーニング前の位置67の文字: "${char67}" (コード: ${char67.charCodeAt(0)}, 16進数: 0x${char67.charCodeAt(0).toString(16)})`);
// 位置67の文字をバイナリで表示
let binaryDump = '位置67の文字をバイナリ表現: ';
try {
const code = cleaned.charCodeAt(67);
let binary = code.toString(2);
while (binary.length < 8) binary = '0' + binary;
binaryDump += binary;
console.log(binaryDump);
} catch (e) {
console.error('バイナリダンプ失敗:', e.message);
}
}
// マルチステップクリーニング
try {
// ステップ1: マークダウンコードブロック(```json```)を削除
const jsonBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (jsonBlockMatch && jsonBlockMatch[1]) {
cleaned = jsonBlockMatch[1].trim();
console.log('マークダウンコードブロックを除去しました');
}
// ステップ2: JSON本体の抽出({から始まり}で終わる部分を探す)
const jsonObjectMatch = cleaned.match(/{[\s\S]*}/);
if (jsonObjectMatch) {
cleaned = jsonObjectMatch[0];
console.log('JSONオブジェクト本体を抽出しました');
}
// ステップ3: すべての制御文字とUnicode特殊文字を削除
cleaned = cleaned
.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF\u2028\u2029]/g, '') // 制御文字とゼロ幅文字
.replace(/\uFEFF/g, '') // BOMを明示的に削除
.replace(/,\s*}/g, '}') // 末尾のカンマ
.replace(/,\s*]/g, ']') // 配列末尾のカンマ
.replace(/"\s*:\s*"/g, '":"') // 引用符の間の余分なスペース
.replace(/\\(?!["\\/bfnrt])/g, ''); // 無効なエスケープシーケンス
// ステップ3.5: 日本語などの全角文字は保持しつつ、不正な非ASCII文字のみを削除
cleaned = cleaned.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
// ステップ4: 位置67周辺の文字を優先的に修正(最も一般的な問題箇所)
if (cleaned.length >= 68) {
// 位置67の前後3文字を確認
for (let i = Math.max(0, 67 - 3); i <= Math.min(cleaned.length - 1, 67 + 3); i++) {
const charToCheck = cleaned.charAt(i);
// 不審な文字や制御文字があれば削除
if (!/[\x20-\x7E\u3000-\u9FFF]/.test(charToCheck) || charToCheck === '\\' ||
(charToCheck === '"' && (cleaned.charAt(i-1) !== '\\' && cleaned.charAt(i+1) !== ':' && cleaned.charAt(i-1) !== ':'))) {
console.log(`位置${i}の問題文字「${charToCheck}」を削除します`);
cleaned = cleaned.substring(0, i) + cleaned.substring(i + 1);
i--; // インデックスを調整
}
}
}
// ステップ5: 特に位置67を強制的に確認(最も多い問題箇所)
if (cleaned.length >= 67) {
const char67 = cleaned.charAt(67);
// 位置67が特に問題を起こしやすいため特別チェック
if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67) || char67 === '\\' ||
(char67 === '"' && (cleaned.charAt(66) !== '\\' && cleaned.charAt(68) !== ':' && cleaned.charAt(66) !== ':'))) {
console.log(`位置67の問題文字「${char67}」を強制削除します`);
cleaned = cleaned.substring(0, 67) + cleaned.substring(68);
}
}
} catch (e) {
console.error('クリーニング処理中にエラー:', e.message);
}
// 削除された文字数を報告
const removedChars = originalLength - cleaned.length;
console.log(`クリーニングにより ${removedChars} 文字を削除しました`);
// JSONが解析可能か確認(成功すれば報告のみ)
try {
JSON.parse(cleaned);
console.log('クリーニング後のJSONは正常にパースできました!');
} catch (e) {
console.log(`クリーニング後もパースできません: ${e.message}`);
console.log('クリーニング後の文字列(最初の100文字):', cleaned.substring(0, 100));
// 最後の手段として一般的なJSONの問題を修正
try {
// 引用符が閉じられていない問題を修正
const quoteCount = (cleaned.match(/"/g) || []).length;
if (quoteCount % 2 !== 0) {
console.log('引用符の数が奇数です。修正を試みます');
// 未閉じの引用符を特定して閉じる
let inString = false;
let escapedChar = false;
let fixedJson = '';
for (let i = 0; i < cleaned.length; i++) {
const char = cleaned.charAt(i);
if (char === '\\' && !escapedChar) {
escapedChar = true;
fixedJson += char;
continue;
}
if (char === '"' && !escapedChar) {
inString = !inString;
}
escapedChar = false;
fixedJson += char;
}
// 文字列が閉じられていなければ閉じる
if (inString) {
fixedJson += '"';
console.log('未閉じの引用符を閉じました');
}
cleaned = fixedJson;
}
// オブジェクトが閉じられていない問題を修正
const openBraces = (cleaned.match(/{/g) || []).length;
const closeBraces = (cleaned.match(/}/g) || []).length;
if (openBraces > closeBraces) {
// 閉じられていない中括弧を追加
const diff = openBraces - closeBraces;
cleaned = cleaned + '}'.repeat(diff);
console.log(`${diff}個の閉じ中括弧を追加しました`);
}
// 配列が閉じられていない問題を修正
const openBrackets = (cleaned.match(/\[/g) || []).length;
const closeBrackets = (cleaned.match(/\]/g) || []).length;
if (openBrackets > closeBrackets) {
// 閉じられていない角括弧を追加
const diff = openBrackets - closeBrackets;
cleaned = cleaned + ']'.repeat(diff);
console.log(`${diff}個の閉じ角括弧を追加しました`);
}
// 最終チェック - パース可能か確認
try {
JSON.parse(cleaned);
console.log('構造の修正により、JSONは正常にパースできるようになりました!');
} catch (finalError) {
console.log('すべての修正を適用しても解析エラーが残っています:', finalError.message);
}
} catch (fixError) {
console.error('構造修正中にエラー:', fixError.message);
}
}
return cleaned;
}
// ユーザー分析用の簡易分析関数を追加
function createSimpleUserAnalysis(messages, userName) {
console.log('簡易ユーザー分析を実行します');
// すべてのテキストを結合
const allText = messages.map(msg => msg.text || '').join(' ');
// 簡易的なキーワード抽出
const words = allText.toLowerCase().split(/\s+/);
const wordCounts = {};
const ignoreWords = ['て', 'に', 'は', 'を', 'の', 'が', 'と', 'です', 'ます', 'した', 'から', 'ない'];
words.forEach(word => {
if (word.length > 1 && !ignoreWords.includes(word)) {
wordCounts[word] = (wordCounts[word] || 0) + 1;
}
});
const keywords = Object.entries(wordCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(entry => entry[0]);
// 文章の長さを分析
const avgLength = Math.round(allText.length / Math.max(1, messages.length));
return {
userName: userName,
summary: `${userName}さんは、長文メッセージを投稿する傾向があります。平均文字数は約${avgLength}文字です。`,
communicationStyle: "詳細な情報を提供する傾向があります。",
strengths: ["詳細な情報提供", "丁寧な説明", "文章での表現力"],
interests: keywords.slice(0, 3).map(kw => `「${kw}」に関連するトピック`),
workStyle: "文章での情報共有を重視しているようです。",
projectAreas: keywords.slice(3, 5),
technicalExpertise: keywords.slice(5, 7),
tips: ["詳細な情報を提供することを好む傾向があります", "文章でのコミュニケーションが得意なようです", "簡潔な返信よりも詳細な説明が好みかもしれません"],
bestPractices: ["長文の返信に対応する", "詳細な情報を共有する", "文脈を意識したコミュニケーション"],
tags: keywords.slice(0, 5)
};
}
// APIレスポンス用のJSON安全化関数
function sanitizeApiResponse(data) {
try {
// すでにJSON文字列の場合は安全なJSONとして処理
if (typeof data === 'string') {
try {
// いったんパースして検証
const parsed = JSON.parse(data);
// 成功したら文字列に戻す
return JSON.stringify(parsed);
} catch (parseError) {
console.error('応答文字列のJSON解析エラー:', parseError.message);
// 問題のある場合はクリーニング処理を実行
return JSON.stringify(cleanupAllProblematicCharacters(data));
}
}
// オブジェクトの場合は安全な形式に変換
return JSON.stringify(data, (key, value) => {
// 特殊文字や不正な文字を含む可能性のある文字列を処理
if (typeof value === 'string') {
// 制御文字を削除
return value.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, '');
}
return value;
});
} catch (error) {
console.error('APIレスポンス安全化エラー:', error);
// エラーが発生した場合でも何らかの応答を返す
return JSON.stringify({
error: 'レスポンス処理中にエラーが発生しました',
details: error.message
});
}
}
// ピックアップユーザーを取得するAPI(発信数5件以上のユーザーからランダムに1人選ぶ)
app.get('/api/pickup-user', async (req, res) => {
try {
// 今日の日付を取得(この値はログ表示やデバッグ用)
const today = getJSTDateString();
console.log(`今日の日付: ${today}`);
// 今日の日付でピックアップユーザーを検索
const targetDate = today;
console.log(`検索対象日: ${targetDate}`);
// 指定した日付のピックアップユーザーをランダムに1人取得
db.get('SELECT * FROM daily_pickup_user WHERE pickup_date = ? ORDER BY RANDOM() LIMIT 1', [targetDate], async (err, pickupUser) => {
if (err) {
console.error('イチ推しbraver取得エラー:', err);
return res.status(500).json({ error: 'イチ推しbraverの取得に失敗しました' });
}
// イチ推しbraverが存在する場合はそれを返す
if (pickupUser) {
console.log(`検索日(${targetDate})のイチ推しbraverを返します: ${pickupUser.display_name || pickupUser.real_name || pickupUser.name}`);
console.log(`【活動データ】投稿数: ${pickupUser.message_count}, 返信数: ${pickupUser.reply_count}, 出勤数: ${pickupUser.attendance_count}, リアル出社: ${pickupUser.office_count}, 週報数: ${pickupUser.report_count}, 送信スタンプ: ${pickupUser.sent_reaction_count}, 受信スタンプ: ${pickupUser.received_reaction_count}`);
// 表示名を設定
pickupUser.displayName = pickupUser.display_name || pickupUser.real_name || pickupUser.name || 'Unknown';
// 結果を返す
return res.json({
success: true,
user: pickupUser
});
}
// 指定日のイチ推しbraverが見つからない場合は、同期処理がまだ実行されていない可能性がある
// 安全のため、古い選出ロジックをバックアップとして実行(前回選択されたユーザーは除外)
console.log(`${targetDate}のイチ推しbraverが見つかりません。バックアップ選出処理を実行します。`);
// 前回選択されたユーザーIDを取得(クエリパラメータから)
const previousUserId = req.query.previousUserId || '';
// 発信数5件以上のユーザーをクエリ(シンプル化したクエリ)
const pickupQuery = `
SELECT
u.user_id,
u.name,
u.real_name,
u.display_name,
REPLACE(u.avatar, '_72', '_512') AS avatar,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
(SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
) AS attendance_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
) AS office_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND length(text) >= 50
) AS report_count,
0 AS sent_reaction_count,
0 AS received_reaction_count
FROM
slack_users u
WHERE
u.is_bot = 0
AND (
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
) >= 5
`;
// クエリを実行
db.all(pickupQuery, [], async (err, users) => {
if (err) {
console.error('ピックアップユーザー取得エラー:', err);
return res.status(500).json({ error: 'ピックアップユーザーの取得に失敗しました' });
}
// 条件に合うユーザーがいない場合
if (users.length === 0) {
return res.json({
success: false,
error: '発信数5件以上のユーザーが見つかりませんでした'
});
}
// ランダムに1人選ぶ
const randomIndex = Math.floor(Math.random() * users.length);
const selectedUser = users[randomIndex];
// 表示名を設定
selectedUser.displayName = selectedUser.display_name || selectedUser.real_name || selectedUser.name || 'Unknown';
// 高画質アバターURLを確保(バックアップ)
if (selectedUser.avatar && selectedUser.avatar.includes('_72')) {
selectedUser.avatar_hd = selectedUser.avatar.replace('_72', '_512');
} else {
selectedUser.avatar_hd = selectedUser.avatar;
}
// 活動データから基本情報を作成
const messageCount = selectedUser.message_count || 0;
const replyCount = selectedUser.reply_count || 0;
const reportCount = selectedUser.report_count || 0;
const attendanceCount = selectedUser.attendance_count || 0;
const officeCount = selectedUser.office_count || 0;
const activityInfo = `${selectedUser.displayName}さんは、投稿数${messageCount}件、発信数${reportCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションが特徴的です!`;
// 簡易的な分析結果を作成
selectedUser.analysis = activityInfo;
// 結果を返す
return res.json({
success: true,
user: {
...selectedUser,
// 数値型を確保
message_count: parseInt(selectedUser.message_count || 0),
reply_count: parseInt(selectedUser.reply_count || 0),
attendance_count: parseInt(selectedUser.attendance_count || 0),
office_count: parseInt(selectedUser.office_count || 0),
report_count: parseInt(selectedUser.report_count || 0),
sent_reaction_count: parseInt(selectedUser.sent_reaction_count || 0),
received_reaction_count: parseInt(selectedUser.received_reaction_count || 0)
}
});
});
});
} catch (error) {
console.error('リアクションテストに失敗しました:', error);
res.status(500).json({
success: false,
error: `リアクションテストに失敗しました: ${error.message}`
});
}
});
// reactions.get APIをテストするエンドポイント
app.get('/api/test-reactions-direct', async (req, res) => {
try {
console.log('リアクション直接取得テストを実行します...');
// テスト用のチャンネルID(最初のチャンネルを使用)
const testChannelId = slackChannelIds[0];
console.log(`テスト用チャンネルID: ${testChannelId}`);
// チャンネル情報を取得してログに出力
const channelInfo = await slackClient.conversations.info({
channel: testChannelId
});
console.log(`テスト用チャンネル名: ${channelInfo.channel.name}`);
// 過去3ヶ月のメッセージを取得
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oldestTimestamp = threeMonthsAgo.getTime() / 1000;
// チャンネル履歴を取得(最新100件のメッセージ)
const result = await slackClient.conversations.history({
channel: testChannelId,
limit: 100,
oldest: oldestTimestamp
});
console.log(`メッセージ件数: ${result.messages.length}件`);
// 各メッセージのリアクション情報を取得
let totalReactions = 0;
let messagesWithReactions = 0;
const reactionResults = [];
// データベース接続
const db = new sqlite3.Database('slack_data.db');
try {
// トランザクション開始
await new Promise((resolve, reject) => {
db.run('BEGIN TRANSACTION', err => {
if (err) reject(err);
else resolve();
});
});
// リアクションテーブルをクリア
await new Promise((resolve, reject) => {
db.run('DELETE FROM slack_reactions', err => {
if (err) reject(err);
else resolve();
});
});
// プリペアドステートメント作成
const stmt = db.prepare(`
INSERT INTO slack_reactions (message_id, user_id, name, timestamp)
VALUES (?, ?, ?, ?)
`);
// 最初の10件のメッセージについてリアクション情報を取得
for (const msg of result.messages.slice(0, 10)) {
try {
console.log(`メッセージ確認 (ts=${msg.ts}): テキスト="${msg.text.substring(0, 30)}..."`);
// reactions.get APIを使用してリアクション情報を取得
const reactionInfo = await slackClient.reactions.get({
channel: testChannelId,
timestamp: msg.ts,
full: true
});
console.log(`リアクション取得結果:`, JSON.stringify(reactionInfo, null, 2));
if (reactionInfo.message && reactionInfo.message.reactions && reactionInfo.message.reactions.length > 0) {
messagesWithReactions++;
for (const reaction of reactionInfo.message.reactions) {
if (reaction.users && reaction.users.length > 0) {
for (const userId of reaction.users) {
// リアクションをDBに保存
stmt.run(msg.ts, userId, reaction.name, msg.ts);
totalReactions++;
}
}
}
reactionResults.push({
ts: msg.ts,
text: msg.text.substring(0, 50) + (msg.text.length > 50 ? '...' : ''),
reactions: reactionInfo.message.reactions
});
}
} catch (reactionError) {
console.error(`メッセージ ${msg.ts} のリアクション取得エラー:`, reactionError);
}
// API制限を考慮して少し待機
await new Promise(resolve => setTimeout(resolve, 200));
}
// ステートメントをfinalize
stmt.finalize();
// トランザクションをコミット
await new Promise((resolve, reject) => {
db.run('COMMIT', err => {
if (err) reject(err);
else resolve();
});
});
console.log(`${totalReactions}件のリアクションをデータベースに保存しました`);
} catch (dbError) {
console.error('データベース操作エラー:', dbError);
// エラーが発生した場合はロールバック
await new Promise((resolve) => {
db.run('ROLLBACK', () => resolve());
});
} finally {
// データベースを閉じる
db.close();
}
// レスポンスを返す
res.json({
success: true,
messageCount: result.messages.length,
messagesWithReactions: messagesWithReactions,
totalReactions: totalReactions,
samples: reactionResults
});
} catch (error) {
console.error('リアクション直接取得テストエラー:', error);
res.status(500).json({
success: false,
error: `リアクション直接取得テストに失敗しました: ${error.message}`
});
}
});
// アプリ設定を取得する関数
function getSettings() {
try {
// デフォルト設定
const defaultSettings = {
aiPrompt: '以下のSlackメッセージからユーザーの特徴を分析してください。簡潔に200文字程度でまとめてください。',
syncPeriod: 90, // 90日(3ヶ月)
characterImage: '/images/brave-character.png', // デフォルトのキャラクター画像
slackBotToken: process.env.SLACK_BOT_TOKEN || null,
slackChannelIds: process.env.SLACK_CHANNEL_IDS ? process.env.SLACK_CHANNEL_IDS.split(',') : [],
anthropicApiKey: process.env.ANTHROPIC_API_KEY || null,
openaiApiKey: process.env.OPENAI_API_KEY || null
};
// 設定ファイルからの読み込みを試みる
try {
const settingsFile = fs.readFileSync('settings.json', 'utf8');
const settings = JSON.parse(settingsFile);
console.log('設定ファイルから設定を読み込みました');
return { ...defaultSettings, ...settings };
} catch (readError) {
console.log('設定ファイルの読み込みに失敗しました: ' + readError.message);
return defaultSettings;
}
} catch (error) {
console.error('設定ファイルの読み込みに失敗しました:', error);
return {
aiPrompt: '以下のSlackメッセージからユーザーの特徴を分析してください。簡潔に200文字程度でまとめてください。',
syncPeriod: 90, // 90日(3ヶ月)
characterImage: '/images/brave-character.png', // デフォルトのキャラクター画像
slackBotToken: process.env.SLACK_BOT_TOKEN || null,
slackChannelIds: process.env.SLACK_CHANNEL_IDS ? process.env.SLACK_CHANNEL_IDS.split(',') : [],
anthropicApiKey: process.env.ANTHROPIC_API_KEY || null,
openaiApiKey: process.env.OPENAI_API_KEY || null
};
}
}
// 「今日の再同期」用API
app.get('/api/clean-sync-slack-data', async (req, res) => {
try {
console.log('クリーンアップおよび再同期を開始します...');
// 最終同期日時を先に更新(同時実行防止のため)
try {
// 日本時間の現在日時を取得
const jstNow = getJSTDate();
const timestamp = jstNow.getTime();
const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
console.log(`同期開始前に最終同期日時を更新します (JST): ${datetimeStr}, ${timestamp}`);
// 既存の最終同期レコードを取得
const lastSyncData = await new Promise((resolve, reject) => {
db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
if (err) {
console.error('最終同期データの確認に失敗:', err);
reject(err);
return;
}
resolve(row);
});
});
if (lastSyncData) {
// 既存のレコードがある場合は更新
await new Promise((resolve, reject) => {
db.run(
'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?',
[datetimeStr, timestamp, lastSyncData.id],
function(err) {
if (err) {
console.error('最終同期日時の更新に失敗:', err);
reject(err);
} else {
console.log(`最終同期日時を更新しました (JST): ${datetimeStr}, ${timestamp}`);
resolve();
}
}
);
});
} else {
// 既存のレコードがない場合は新規作成
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO last_sync_date (last_sync, last_sync_timestamp) VALUES (?, ?)',
[datetimeStr, timestamp],
function(err) {
if (err) {
console.error('最終同期日時の新規作成に失敗:', err);
reject(err);
} else {
console.log(`最終同期日時を新規作成しました (JST): ${datetimeStr}, ${timestamp}`);
resolve();
}
}
);
});
}
} catch (syncDateError) {
console.error('最終同期日時の更新に失敗:', syncDateError);
// 同期日時の更新に失敗しても処理は続行
}
// クリーンアップ処理
try {
// 今日の日付を取得(日本時間)
const today = getJSTDateString();
// 今日のイチ推しbraverデータを削除
await new Promise((resolve, reject) => {
db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
if (err) {
console.error('イチ推しbraverリセットエラー:', err);
reject(err);
} else {
console.log(`今日のイチ推しbraverをリセットしました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
// 1週間前のタイムスタンプを計算(日本時間ベース)
const oneWeekAgo = getJSTDate();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const oneWeekAgoTimestamp = oneWeekAgo.getTime() / 1000;
console.log(`直近1週間分(${oneWeekAgo.toLocaleString('ja-JP')}以降)のSlackデータを削除します...`);
// 削除処理をPromiseとして実行
const deleteMessages = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_messages WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
if (err) {
console.error('直近1週間のメッセージ削除エラー:', err);
reject(err);
} else {
console.log(`直近1週間のメッセージを削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
const deleteThreadReplies = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_thread_replies WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
if (err) {
console.error('直近1週間のスレッド返信削除エラー:', err);
reject(err);
} else {
console.log(`直近1週間のスレッド返信を削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
const deleteReactions = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_reactions WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
if (err) {
console.error('直近1週間のリアクション削除エラー:', err);
reject(err);
} else {
console.log(`直近1週間のリアクションを削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
// すべての削除処理を待つ
await Promise.all([deleteMessages, deleteThreadReplies, deleteReactions]);
console.log('すべてのデータ削除処理が完了しました');
// 全チャンネルの同期を実行
if (!slackToken || !slackChannelIds.length) {
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
const channelsToSync = global.slackChannelIds || slackChannelIds;
console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
// チャンネルごとの同期結果を保存する配列
const syncResults = [];
const errorChannels = [];
// APIレート制限を回避するためのディレイ関数
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// 各チャンネルを順番に処理(レート制限を回避するため順次処理)
for (const channelId of channelsToSync) {
try {
console.log(`チャンネル ${channelId} の同期を開始します...`);
// チャンネル情報を取得 - レート制限対策
let channelInfo;
try {
channelInfo = await slackClient.conversations.info({
channel: channelId
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
channelInfo = await slackClient.conversations.info({
channel: channelId
});
} else {
throw error;
}
}
const channelName = channelInfo.channel.name || 'Unknown Channel';
console.log(`チャンネル名: ${channelName}`);
// APIリクエスト間にディレイを挿入してレート制限を回避
await delay(1000);
// チャンネル履歴を取得 - レート制限対策
let result;
try {
result = await slackClient.conversations.history({
channel: channelId,
limit: 100
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
result = await slackClient.conversations.history({
channel: channelId,
limit: 100
});
} else {
throw error;
}
}
console.log(`チャンネル ${channelName} から ${result.messages.length} 件のメッセージを取得`);
// メッセージを保存
for (const message of result.messages) {
// メッセージのタイムスタンプをチェック
if (message.ts && parseFloat(message.ts) >= oneWeekAgoTimestamp) {
// メッセージを保存
const messageType = message.subtype || 'message';
const userId = message.user || 'unknown_user';
const text = message.text || '';
const timestamp = message.ts;
// DBに保存
db.run(
'INSERT OR REPLACE INTO slack_messages (message_id, channel_id, channel_name, user_id, text, message_type, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
timestamp,
channelId,
channelName,
userId,
text,
messageType,
timestamp,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`メッセージ保存エラー: ${err.message}`);
}
}
);
// スレッド返信があれば取得
if (message.thread_ts && message.reply_count > 0) {
console.log(`スレッド検出: message_id=${message.ts}, thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
console.log(`スレッド返信を取得します: thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
// APIリクエスト間にディレイを挿入してレート制限を回避
await delay(1000);
// スレッド返信を取得 - レート制限対策
let repliesResult;
try {
repliesResult = await slackClient.conversations.replies({
channel: channelId,
ts: message.thread_ts,
limit: 100
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
repliesResult = await slackClient.conversations.replies({
channel: channelId,
ts: message.thread_ts,
limit: 100
});
} else {
console.error(`スレッド返信取得エラー: ${error.message}`);
continue; // このスレッドをスキップして次へ
}
}
console.log(`スレッド返信取得結果: ${repliesResult.messages.length}件`);
// スレッド返信を保存
for (const reply of repliesResult.messages) {
// 親メッセージは除外
if (reply.ts === message.thread_ts) continue;
const replyUserId = reply.user || 'unknown_user';
const replyText = reply.text || '';
const replyTimestamp = reply.ts;
// DBに保存
db.run(
'INSERT OR REPLACE INTO slack_thread_replies (reply_id, thread_ts, message_id, channel_id, channel_name, user_id, text, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
replyTimestamp,
message.thread_ts,
replyTimestamp,
channelId,
channelName,
replyUserId,
replyText,
replyTimestamp,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`スレッド返信保存エラー: ${err.message}`);
}
}
);
}
// API呼び出し間に短いディレイを入れる
await delay(500);
}
// リアクションを処理
if (message.reactions && message.reactions.length > 0) {
console.log(`リアクション検出: message_id=${message.ts}, reaction_count=${message.reactions.length}`);
console.log(`リアクション詳細: ${JSON.stringify(message.reactions, null, 2)}`);
for (const reaction of message.reactions) {
const emojiName = reaction.name;
const userIds = reaction.users || [];
console.log(`リアクション「${emojiName}」のユーザー数: ${userIds.length}`);
// ユーザーごとにリアクションを保存
for (const reactUserId of userIds) {
db.run(
'INSERT OR REPLACE INTO slack_reactions (message_id, channel_id, user_id, name, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[
message.ts,
channelId,
reactUserId,
emojiName,
message.ts,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`リアクション保存エラー: ${err.message}`);
} else {
console.log(`リアクション保存成功: message_id=${message.ts}, user=${reactUserId}, emoji=${emojiName}`);
}
}
);
}
}
} else if (message.reactions === undefined) {
console.log(`メッセージID: ${message.ts} にはリアクションがありません`);
}
}
// API呼び出し間に短いディレイを入れる
await delay(500);
}
syncResults.push({
channelId: channelId,
channelName: channelName,
messageCount: result.messages.length,
success: true
});
// チャンネルごとに少し長めのディレイを入れる
await delay(2000);
} catch (channelError) {
console.error(`チャンネル ${channelId} の同期エラー:`, channelError);
errorChannels.push(channelId);
syncResults.push({
channelId: channelId,
error: channelError.message,
success: false
});
// エラー後も少し待機
await delay(3000);
}
}
// 処理結果の概要を出力
const allChannelsProcessed = syncResults.length;
const successChannels = syncResults.filter(r => r.success).length;
console.log(`全チャンネルの同期完了: 成功=${successChannels}件, 失敗=${errorChannels.length}件`);
// 同期完了後にイチ推しbraverを再選出
try {
console.log('同期完了後にイチ推しbraverを再選出します');
await resetAndSelectDailyPickupUser();
console.log('イチ推しbraverの再選出が完了しました');
} catch (pickupError) {
console.error('イチ推しbraver再選出エラー:', pickupError);
}
return res.json({
success: true,
channelCount: allChannelsProcessed,
errorChannels: errorChannels
});
} catch (syncError) {
console.error(`同期処理エラー: ${syncError.message}`);
return res.status(500).json({ error: `同期処理エラー: ${syncError.message}` });
}
} catch (error) {
console.error(`クリーンアップ同期エラー: ${error.message}`);
return res.status(500).json({ error: `クリーンアップ同期エラー: ${error.message}` });
}
});
// Slackデータを差分同期するAPI
app.get('/api/sync-diff-slack-data', async (req, res) => {
try {
console.log('データ同期リクエストを受信しました');
if (!slackToken) {
console.error('Slackトークンが設定されていません');
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
if (!slackChannelIds || !slackChannelIds.length) {
console.error('SlackチャンネルIDが設定されていません');
return res.status(500).json({
error: 'SlackチャンネルIDが設定されていません。環境変数を確認してください。'
});
}
console.log('Slackデータの差分同期を開始...');
console.log(`設定されているチャンネル数: ${slackChannelIds.length}`);
console.log(`チャンネルID: ${slackChannelIds.join(', ')}`);
// 同期するチャンネルの指定
const requestedChannelId = req.query.channelId;
let channelsToSync = [];
if (requestedChannelId) {
// 特定のチャンネルが指定された場合
channelsToSync = [requestedChannelId];
console.log(`指定されたチャンネル ${requestedChannelId} のみを同期します`);
} else {
// すべてのチャンネルを同期
channelsToSync = global.slackChannelIds || slackChannelIds;
console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
}
// チャンネルごとの同期結果を保存する配列
const syncResults = [];
// 各チャンネルを順番に処理
for (const channelId of channelsToSync) {
try {
console.log(`チャンネル ${channelId} の同期を開始します...`);
// チャンネル情報の取得を試みる
const channelInfo = await slackClient.conversations.info({
channel: channelId
}).catch(error => {
console.error(`チャンネル ${channelId} の情報取得に失敗:`, error.message);
throw error;
});
console.log(`チャンネル ${channelId} (${channelInfo.channel.name}) の同期を続行します`);
// 以下、既存の同期処理を続行
// ... existing code ...
} catch (channelSyncError) {
console.error(`チャンネル ${channelId} の同期エラー:`, channelSyncError);
syncResults.push({
channelId: channelId,
error: channelSyncError.message,
success: false
});
}
}
// 同期完了後にイチ推しbraverを再選出
try {
console.log('同期完了後にイチ推しbraverを再選出します');
await resetAndSelectDailyPickupUser();
console.log('イチ推しbraverの再選出が完了しました');
} catch (pickupError) {
console.error('イチ推しbraver再選出エラー:', pickupError);
}
return res.json({
success: true,
syncedChannels: syncResults,
channelCount: syncResults.length,
period: `${getJSTDate().toLocaleDateString('ja-JP')} までの3ヶ月分`,
message: "スタンプ情報も含めて全データを同期しました"
});
} catch (error) {
console.error('Slackデータ差分同期エラー:', error);
return res.status(500).json({ error: `同期処理中にエラーが発生しました: ${error.message}` });
}
});
// 朝の自動同期用API
app.get('/api/morning-sync', async (req, res) => {
try {
console.log('朝の自動同期を開始します...');
// 環境変数の確認
if (!slackToken) {
console.error('Slackトークンが設定されていません');
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
if (!slackChannelIds || !slackChannelIds.length) {
console.error('SlackチャンネルIDが設定されていません');
return res.status(500).json({
error: 'SlackチャンネルIDが設定されていません。環境変数を確認してください。'
});
}
// データベースの接続確認
db.get("SELECT 1", (err) => {
if (err) {
console.error('データベース接続エラー:', err);
return res.status(500).json({
error: 'データベース接続に失敗しました。'
});
}
});
// 最終同期日時を確認して、24時間以上経過していれば同期を実行
try {
// 現在の時刻(日本時間)
const currentTime = getJSTDate().getTime();
// 最終同期日時を取得
const lastSyncData = await new Promise((resolve, reject) => {
db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
if (err) {
console.error('最終同期データの確認に失敗:', err);
reject(err);
return;
}
resolve(row);
});
});
// 同期が必要かどうかを判断
let needSync = false;
let reason = "";
if (!lastSyncData) {
console.log('初回アクセス: 同期データがありません');
needSync = true;
reason = "初回アクセスのため";
} else {
// 最終同期タイムスタンプから24時間以上経過しているか確認
const lastSyncTimestamp = Number(lastSyncData.last_sync_timestamp);
if (isNaN(lastSyncTimestamp)) {
console.log('タイムスタンプがNaNです:', lastSyncData.last_sync_timestamp);
needSync = true;
reason = "有効なタイムスタンプがないため";
} else {
const timeDiff = currentTime - lastSyncTimestamp;
const hoursDiff = timeDiff / (1000 * 60 * 60); // ミリ秒 → 時間
console.log(`前回の同期から${hoursDiff.toFixed(2)}時間経過しています`);
console.log(`最終同期タイムスタンプ: ${lastSyncTimestamp} (${new Date(lastSyncTimestamp).toISOString()})`);
console.log(`現在のタイムスタンプ (JST): ${currentTime} (${new Date(currentTime).toISOString()})`);
console.log(`時間差: ${timeDiff}ms = ${hoursDiff}時間`);
// 24時間以上経過している場合は同期が必要
if (hoursDiff >= 24) {
needSync = true;
reason = `前回の同期から${Math.floor(hoursDiff)}時間経過したため`;
console.log(`同期条件成立: ${reason}`);
} else {
console.log('前回の同期から24時間経過していないため同期をスキップします');
return res.json({
success: true,
message: "前回の同期から24時間経過していないため同期をスキップしました",
lastSync: new Date(lastSyncTimestamp).toISOString(),
hoursSinceLastSync: hoursDiff.toFixed(2)
});
}
}
}
// 同期が必要な場合のみ実行
if (needSync) {
console.log(`同期を実行します(理由: ${reason})...`);
// clean-sync-slack-dataエンドポイントを内部で呼び出す
console.log('clean-sync-slack-dataエンドポイントを内部で呼び出します...');
const response = await fetch(`http://localhost:${port}/api/clean-sync-slack-data`, {
method: 'GET'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`clean-sync-slack-dataの呼び出しに失敗しました: ${response.status} ${errorText}`);
}
const result = await response.json();
// clean-sync-slack-dataの結果をそのまま返す
console.log('朝の自動同期が完了しました');
return res.json({
...result,
message: "朝の自動同期が完了しました",
reason: reason
});
}
} catch (syncError) {
console.error('clean-sync-slack-data呼び出しエラー:', syncError);
return res.status(500).json({
error: `同期処理中にエラーが発生しました: ${syncError.message}`
});
}
} catch (error) {
console.error('朝の自動同期エラー:', error);
return res.status(500).json({
error: `同期処理中にエラーが発生しました: ${error.message}`
});
}
});
// 最終同期日時をチェックし、必要に応じて同期を実行する関数
async function checkAndSyncIfNeeded() {
console.log('\n==========================');
console.log('checkAndSyncIfNeeded関数が呼び出されました');
console.log('==========================\n');
try {
console.log('\n======== 自動同期チェック開始 ========');
const today = getJSTDateString();
const currentTime = getJSTDate().getTime();
console.log(`今日の日付 (JST): ${today}`);
console.log(`現在のタイムスタンプ (JST): ${currentTime}`);
// DBから最終同期日時を確認
try {
const lastSyncData = await new Promise((resolve, reject) => {
db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
if (err) {
console.error('最終同期データの確認に失敗:', err);
reject(err);
return;
}
if (row) {
console.log('最終同期データ:', JSON.stringify(row, null, 2));
} else {
console.log('最終同期データが存在しません');
}
resolve(row);
});
});
// 同期が必要かどうかを判断
let needSync = false;
let reason = "";
if (!lastSyncData) {
console.log('初回アクセス: 同期データがありません');
// 初回実行
needSync = true;
reason = "初回アクセスのため";
} else {
// 最終同期タイムスタンプから24時間以上経過しているか確認
const lastSyncTimestamp = Number(lastSyncData.last_sync_timestamp);
if (isNaN(lastSyncTimestamp)) {
console.log('タイムスタンプがNaNです:', lastSyncData.last_sync_timestamp);
needSync = true;
reason = "有効なタイムスタンプがないため";
} else {
const timeDiff = currentTime - lastSyncTimestamp;
const hoursDiff = timeDiff / (1000 * 60 * 60); // ミリ秒 → 時間
console.log(`前回の同期から${hoursDiff.toFixed(2)}時間経過しています`);
console.log(`最終同期タイムスタンプ: ${lastSyncTimestamp} (${new Date(lastSyncTimestamp).toISOString()})`);
console.log(`現在のタイムスタンプ (JST): ${currentTime} (${new Date(currentTime).toISOString()})`);
console.log(`時間差: ${timeDiff}ms = ${hoursDiff}時間`);
// 24時間以上経過している場合は同期が必要
if (hoursDiff >= 24) {
needSync = true;
reason = `前回の同期から${Math.floor(hoursDiff)}時間経過したため`;
console.log(`自動同期条件成立: ${reason}`);
} else {
console.log('前回の同期から24時間経過していないためスキップします');
// 最終同期日時の取得(YYYY-MM-DD形式に変換)
let lastSyncDate;
try {
lastSyncDate = new Date(lastSyncData.last_sync);
const lastSyncDateStr = lastSyncDate.toISOString().split('T')[0];
console.log(`最終同期日: ${lastSyncDateStr}, 今日 (JST): ${today}`);
// 日付が変わった場合はイチ推しBraverの選出のみ行う
if (lastSyncDateStr !== today) {
console.log('最終同期日が今日ではありません。イチ推しBraverを選出します...');
try {
await resetAndSelectDailyPickupUser();
// 最終同期日も更新(日本時間)
const jstNow = getJSTDate();
const timestamp = jstNow.getTime();
const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
console.log(`最終同期日を更新します (JST): ${datetimeStr}, ${timestamp}`);
await new Promise((resolve, reject) => {
db.run(
'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?',
[datetimeStr, timestamp, lastSyncData.id],
function(err) {
if (err) {
console.error('最終同期日の更新に失敗:', err);
reject(err);
} else {
console.log(`最終同期日を更新しました (JST): ${datetimeStr}, ${timestamp}`);
resolve();
}
}
);
});
} catch (syncError) {
console.error('イチ推しbraver選出の実行に失敗:', syncError);
}
}
} catch (dateError) {
console.error('日付変換エラー:', dateError, lastSyncData.last_sync);
}
return; // 同期不要の場合は処理を終了
}
}
}
// 同期が必要な場合は実行
if (needSync) {
console.log(`自動同期を開始します(理由: ${reason})...`);
try {
// clean-sync-slack-dataの処理を呼び出す
console.log(`同期APIを呼び出します: http://localhost:${port}/api/clean-sync-slack-data`);
const response = await fetch(`http://localhost:${port}/api/clean-sync-slack-data`, {
method: 'GET'
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`同期エラー: ${response.status} ${response.statusText} - ${errorText}`);
}
const data = await response.json();
console.log('自動同期完了:', data);
// 最終同期日時を更新(日本時間)
const jstNow = getJSTDate();
const timestamp = jstNow.getTime();
const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
console.log(`同期後に最終同期日時を更新 (JST): ${datetimeStr}, ${timestamp}`);
await new Promise((resolve, reject) => {
db.run(
'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?',
[datetimeStr, timestamp, lastSyncData ? lastSyncData.id : 1],
function(err) {
if (err) {
console.error('最終同期日時の更新に失敗:', err);
reject(err);
} else {
console.log(`最終同期日時を更新しました (JST): ${datetimeStr}, ${timestamp}`);
resolve();
}
}
);
});
} catch (syncError) {
console.error('自動同期の実行に失敗:', syncError);
}
}
} catch (dbError) {
console.error('データベース操作エラー:', dbError);
}
console.log('======== 自動同期チェック終了 ========\n');
} catch (error) {
console.error('同期チェック処理でエラーが発生:', error);
}
}
// メインページへのアクセス時に同期チェックを実行
app.get('/', async (req, res) => {
console.log('ルートパスにアクセスがありました - checkAndSyncIfNeeded関数を呼び出します');
try {
await checkAndSyncIfNeeded();
console.log('checkAndSyncIfNeeded関数の実行が完了しました');
} catch (error) {
console.error('checkAndSyncIfNeeded関数の実行中にエラーが発生しました:', error);
}
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// その他のページアクセス時にも同期チェックを実行
app.get('/simple', async (req, res) => {
await checkAndSyncIfNeeded();
res.sendFile(path.join(__dirname, 'public', 'simple.html'));
});
// ... existing code ...
// 設定を保存する関数
function saveSettings(settings) {
try {
fs.writeFileSync(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
console.log('設定ファイルを保存しました');
return true;
} catch (error) {
console.error('設定ファイル保存エラー:', error);
return false;
}
}
// ... existing code ...
// Google Sheets APIの設定
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const SPREADSHEET_ID = process.env.GOOGLE_SHEET_ID;
const SHEET_NAME = '営業状況';
// Google Sheets APIクライアントの初期化
async function initGoogleSheetsClient() {
try {
const auth = new JWT({
email: process.env.GOOGLE_CLIENT_EMAIL,
key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'),
scopes: SCOPES,
});
const sheets = google.sheets({ version: 'v4', auth });
return sheets;
} catch (error) {
console.error('Google Sheets APIクライアントの初期化に失敗しました:', error);
throw error;
}
}
// 営業データの取得
async function getSalesData() {
try {
const sheets = await initGoogleSheetsClient();
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:Z`,
});
const rows = response.data.values;
if (!rows || rows.length === 0) {
console.log('データが見つかりませんでした。');
return [];
}
// ヘッダー行を取得
const headers = rows[0];
const data = rows.slice(1).map(row => {
const item = {};
headers.forEach((header, index) => {
item[header] = row[index] || '';
});
return item;
});
return data;
} catch (error) {
console.error('営業データの取得に失敗しました:', error);
throw error;
}
}
// 営業データを取得するAPIエンドポイント
app.get('/api/sales-data', async (req, res) => {
try {
const salesData = await getSalesData();
res.json(salesData);
} catch (error) {
console.error('営業データの取得に失敗しました:', error);
res.status(500).json({ error: '営業データの取得に失敗しました' });
}
});
// ... existing code ...
// 「直近3ヶ月のデータ同期」用API
app.get('/api/sync-three-months-data', async (req, res) => {
try {
console.log('直近3ヶ月のデータ同期を開始します...');
// クリーンアップ処理
try {
// 3ヶ月前のタイムスタンプを計算(日本時間ベース)
const threeMonthsAgo = getJSTDate();
threeMonthsAgo.setDate(threeMonthsAgo.getDate() - 90);
const threeMonthsAgoTimestamp = threeMonthsAgo.getTime() / 1000;
console.log(`直近3ヶ月分(${threeMonthsAgo.toLocaleString('ja-JP')}以降)のSlackデータを削除します...`);
// 削除処理をPromiseとして実行
const deleteMessages = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_messages WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
if (err) {
console.error('直近3ヶ月のメッセージ削除エラー:', err);
reject(err);
} else {
console.log(`直近3ヶ月のメッセージを削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
const deleteThreadReplies = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_thread_replies WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
if (err) {
console.error('直近3ヶ月のスレッド返信削除エラー:', err);
reject(err);
} else {
console.log(`直近3ヶ月のスレッド返信を削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
const deleteReactions = new Promise((resolve, reject) => {
db.run('DELETE FROM slack_reactions WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
if (err) {
console.error('直近3ヶ月のリアクション削除エラー:', err);
reject(err);
} else {
console.log(`直近3ヶ月のリアクションを削除しました: ${this.changes}件のデータを削除`);
resolve();
}
});
});
// すべての削除処理を待つ
await Promise.all([deleteMessages, deleteThreadReplies, deleteReactions]);
console.log('すべてのデータ削除処理が完了しました');
// 全チャンネルの同期を実行
if (!slackToken || !slackChannelIds.length) {
return res.status(500).json({
error: 'Slack APIの設定が不足しています。環境変数を確認してください。'
});
}
const channelsToSync = global.slackChannelIds || slackChannelIds;
console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
// チャンネルごとの同期結果を保存
const syncResults = [];
const errorChannels = [];
let totalMessageCount = 0;
let totalReplyCount = 0;
let totalReactionCount = 0;
// APIレート制限を回避するためのディレイ関数
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// 各チャンネルを順番に処理(レート制限を回避するため順次処理)
for (const channelId of channelsToSync) {
try {
console.log(`チャンネル ${channelId} の同期を開始します...`);
// チャンネル情報を取得 - レート制限対策
let channelInfo;
try {
channelInfo = await slackClient.conversations.info({
channel: channelId
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
channelInfo = await slackClient.conversations.info({
channel: channelId
});
} else {
throw error;
}
}
const channelName = channelInfo.channel.name || 'Unknown Channel';
console.log(`チャンネル名: ${channelName}`);
// APIリクエスト間にディレイを挿入してレート制限を回避
await delay(1000);
let cursor = undefined;
let messageCount = 0;
let replyCount = 0;
let reactionCount = 0;
let hasMore = true;
// ページネーションを使用して3ヶ月分のメッセージを取得
while (hasMore) {
// チャンネル履歴を取得 - レート制限対策
let result;
try {
result = await slackClient.conversations.history({
channel: channelId,
limit: 100,
cursor: cursor,
oldest: threeMonthsAgoTimestamp
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
result = await slackClient.conversations.history({
channel: channelId,
limit: 100,
cursor: cursor,
oldest: threeMonthsAgoTimestamp
});
} else {
throw error;
}
}
console.log(`チャンネル ${channelName} から ${result.messages.length} 件のメッセージを取得`);
// メッセージを保存
for (const message of result.messages) {
// メッセージのタイムスタンプをチェック
if (message.ts && parseFloat(message.ts) >= threeMonthsAgoTimestamp) {
// メッセージを保存
const messageType = message.subtype || 'message';
const userId = message.user || 'unknown_user';
const text = message.text || '';
const timestamp = message.ts;
// DBに保存
db.run(
'INSERT OR REPLACE INTO slack_messages (message_id, channel_id, channel_name, user_id, text, message_type, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
timestamp,
channelId,
channelName,
userId,
text,
messageType,
timestamp,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`メッセージ保存エラー: ${err.message}`);
} else {
messageCount++;
}
}
);
// スレッド返信があれば取得
if (message.thread_ts && message.reply_count > 0) {
console.log(`スレッド検出: message_id=${message.ts}, thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
console.log(`スレッド返信を取得します: thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
// APIリクエスト間にディレイを挿入してレート制限を回避
await delay(1000);
// スレッド返信を取得 - レート制限対策
let repliesResult;
try {
repliesResult = await slackClient.conversations.replies({
channel: channelId,
ts: message.thread_ts,
limit: 100
});
} catch (error) {
if (error.code === 'rate_limited') {
console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
await delay((error.retryAfter || 5) * 1000);
repliesResult = await slackClient.conversations.replies({
channel: channelId,
ts: message.thread_ts,
limit: 100
});
} else {
throw error;
}
}
console.log(`スレッド返信を ${repliesResult.messages.length - 1} 件取得しました`);
// 最初のメッセージを除外して返信のみを処理
const replies = repliesResult.messages.slice(1);
for (const reply of replies) {
const replyUserId = reply.user || 'unknown_user';
const replyText = reply.text || '';
const replyTimestamp = reply.ts;
// 返信のタイムスタンプをチェック
if (replyTimestamp && parseFloat(replyTimestamp) >= threeMonthsAgoTimestamp) {
// DBに保存
db.run(
'INSERT OR REPLACE INTO slack_thread_replies (reply_id, thread_ts, message_id, channel_id, channel_name, user_id, text, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
replyTimestamp,
message.thread_ts,
replyTimestamp,
channelId,
channelName,
replyUserId,
replyText,
replyTimestamp,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`スレッド返信保存エラー: ${err.message}`);
} else {
replyCount++;
}
}
);
}
}
}
// リアクションがあれば処理
if (message.reactions && message.reactions.length > 0) {
console.log(`リアクション検出: message_id=${message.ts}, reaction_count=${message.reactions.length}`);
for (const reaction of message.reactions) {
const emojiName = reaction.name;
const userIds = reaction.users || [];
console.log(` 絵文字: ${emojiName}, 付けたユーザー数: ${userIds.length}`);
// 各ユーザーのリアクションを保存
for (const reactUserId of userIds) {
db.run(
'INSERT OR REPLACE INTO slack_reactions (message_id, channel_id, user_id, name, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[
message.ts,
channelId,
reactUserId,
emojiName,
message.ts,
Math.floor(Date.now() / 1000)
],
function(err) {
if (err) {
console.error(`リアクション保存エラー: ${err.message}`);
} else {
reactionCount++;
}
}
);
}
}
}
}
}
// 次のページがあるか確認
cursor = result.response_metadata?.next_cursor;
hasMore = !!cursor;
// ページネーションがある場合、APIレート制限を回避するためのディレイ
if (hasMore) {
console.log(`次のページがあります。次のカーソル: ${cursor}`);
await delay(1000);
}
}
totalMessageCount += messageCount;
totalReplyCount += replyCount;
totalReactionCount += reactionCount;
console.log(`チャンネル ${channelName} の同期が完了しました`);
syncResults.push({
channelId,
channelName,
messageCount,
replyCount,
reactionCount
});
// 次のチャンネルを処理する前にディレイ
await delay(2000);
} catch (error) {
console.error(`チャンネル ${channelId} の同期中にエラーが発生しました:`, error);
errorChannels.push({
channelId,
error: error.message
});
}
}
// 最終結果をログに出力
console.log('=== 3ヶ月分のデータ同期が完了しました ===');
console.log(`取得されたメッセージ総数: ${totalMessageCount}`);
console.log(`取得されたスレッド返信総数: ${totalReplyCount}`);
console.log(`取得されたリアクション総数: ${totalReactionCount}`);
if (errorChannels.length > 0) {
console.log(`エラーが発生したチャンネル数: ${errorChannels.length}`);
errorChannels.forEach(ch => {
console.log(`- チャンネル ${ch.channelId}: ${ch.error}`);
});
}
return res.json({
success: true,
message: '3ヶ月分のデータ同期が完了しました',
messageCount: totalMessageCount,
replyCount: totalReplyCount,
reactionCount: totalReactionCount,
syncResults,
errorChannels
});
} catch (cleanupError) {
console.error('データ同期中にエラーが発生しました:', cleanupError);
return res.status(500).json({ error: `データ同期エラー: ${cleanupError.message}` });
}
} catch (error) {
console.error('3ヶ月分のデータ同期中にエラーが発生しました:', error);
return res.status(500).json({ error: `API実行エラー: ${error.message}` });
}
});
// ... existing code ...
// 最終同期日時情報を返すAPI
app.get('/api/last-sync-info', (req, res) => {
db.get('SELECT last_sync, last_sync_timestamp FROM last_sync_date ORDER BY id DESC LIMIT 1', (err, row) => {
if (err) {
console.error('最終同期データの取得に失敗:', err);
return res.status(500).json({ error: '最終同期情報の取得に失敗しました' });
}
if (!row) {
return res.json({
last_sync: null,
last_sync_timestamp: null,
formatted_date: '未同期'
});
}
let formattedDate = '未同期';
if (row.last_sync) {
try {
// DB内のタイムスタンプは既に日本時間になっているので、そのまま使用
const syncDate = new Date(row.last_sync);
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo' // 明示的に日本時間を指定
};
formattedDate = syncDate.toLocaleString('ja-JP', options);
console.log(`最終同期日時を日本時間でフォーマット: ${formattedDate}`);
} catch (e) {
console.error('日付の変換エラー:', e);
formattedDate = row.last_sync;
}
}
res.json({
last_sync: row.last_sync,
last_sync_timestamp: row.last_sync_timestamp,
formatted_date: formattedDate
});
});
});
// ... existing code ...
// 特定ユーザーの詳細情報を取得するAPI
app.get('/api/users/:userId', async (req, res) => {
try {
const userId = req.params.userId;
console.log(`ユーザー詳細情報リクエスト: ${userId}`);
// 詳細なユーザー情報を取得するSQL
const query = `
SELECT
u.user_id,
u.name,
u.real_name,
u.display_name,
u.avatar,
u.is_bot,
(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
(SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
) AS attendance_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
) AS office_count,
(
SELECT COUNT(*) FROM slack_messages
WHERE user_id = u.user_id
AND length(text) >= 50
) AS weekly_report_count,
(
SELECT COUNT(*) FROM slack_reactions
WHERE user_id = u.user_id
) AS sent_reaction_count,
(
SELECT COUNT(*) FROM slack_reactions r
JOIN slack_messages m ON r.message_id = m.message_id
WHERE m.user_id = u.user_id
) AS received_reaction_count
FROM
slack_users u
WHERE
u.user_id = ?
`;
db.get(query, [userId], async (err, user) => {
if (err) {
console.error('ユーザー詳細情報取得エラー:', err);
return res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
}
if (!user) {
return res.status(404).json({ error: 'ユーザーが見つかりません' });
}
// 追加情報を取得
try {
// 週報・発言リスト(最新20件)を取得 - 50文字以上の長文投稿を取得
const reportsQuery = `
SELECT
m.text,
m.timestamp,
m.channel_name
FROM
slack_messages m
WHERE
m.user_id = ?
AND length(m.text) >= 50
ORDER BY
m.timestamp DESC
LIMIT 20
`;
// 返信リスト(最新20件)を取得
const repliesQuery = `
SELECT
r.text,
r.timestamp,
r.channel_name,
(SELECT text FROM slack_messages WHERE message_id = r.thread_ts) AS parent_text
FROM
slack_thread_replies r
WHERE
r.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
// 送信したスタンプ(最新20件)を取得
const sentStampsQuery = `
SELECT
r.name AS reaction,
r.timestamp,
m.text AS message_text,
(SELECT real_name FROM slack_users WHERE user_id = m.user_id) AS target_user
FROM
slack_reactions r
JOIN
slack_messages m ON r.message_id = m.message_id
WHERE
r.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
// 受信したスタンプ(最新20件)を取得
const receivedStampsQuery = `
SELECT
r.name AS reaction,
r.timestamp,
m.text AS message_text,
(SELECT real_name FROM slack_users WHERE user_id = r.user_id) AS from_user
FROM
slack_reactions r
JOIN
slack_messages m ON r.message_id = m.message_id
WHERE
m.user_id = ?
ORDER BY
r.timestamp DESC
LIMIT 20
`;
// すべてのクエリを並行して実行
const [reports, replies, sentStamps, receivedStamps] = await Promise.all([
new Promise((resolve, reject) => {
db.all(reportsQuery, [userId], (err, rows) => {
if (err) {
console.error('週報・発言履歴取得エラー:', err);
resolve([]);
} else {
console.log(`長文投稿履歴 ${rows.length}件取得:`);
resolve(rows);
}
});
}),
new Promise((resolve, reject) => {
db.all(repliesQuery, [userId], (err, rows) => {
if (err) {
console.error('返信履歴取得エラー:', err);
resolve([]);
} else {
console.log(`返信履歴 ${rows.length}件取得`);
resolve(rows);
}
});
}),
new Promise((resolve, reject) => {
db.all(sentStampsQuery, [userId], (err, rows) => {
if (err) {
console.error('送信スタンプ履歴取得エラー:', err);
resolve([]);
} else {
console.log(`送信スタンプ履歴 ${rows.length}件取得`);
resolve(rows);
}
});
}),
new Promise((resolve, reject) => {
db.all(receivedStampsQuery, [userId], (err, rows) => {
if (err) {
console.error('受信スタンプ履歴取得エラー:', err);
resolve([]);
} else {
console.log(`受信スタンプ履歴 ${rows.length}件取得`);
resolve(rows);
}
});
})
]);
// 結果をユーザーオブジェクトに追加
user.reports = reports.map(report => ({
...report,
date: new Date(parseFloat(report.timestamp) * 1000).toISOString(),
text_preview: report.text && report.text.length > 200 ? report.text.substring(0, 200) + '...' : report.text
}));
user.replies = replies.map(reply => ({
...reply,
date: new Date(parseFloat(reply.timestamp) * 1000).toISOString(),
text_preview: reply.text && reply.text.length > 100 ? reply.text.substring(0, 100) + '...' : reply.text,
parent_preview: reply.parent_text && reply.parent_text.length > 50 ? reply.parent_text.substring(0, 50) + '...' : reply.parent_text
}));
user.sent_stamps = sentStamps.map(stamp => ({
...stamp,
date: new Date(parseFloat(stamp.timestamp) * 1000).toISOString(),
message_preview: stamp.message_text && stamp.message_text.length > 50 ? stamp.message_text.substring(0, 50) + '...' : stamp.message_text
}));
user.received_stamps = receivedStamps.map(stamp => ({
...stamp,
date: new Date(parseFloat(stamp.timestamp) * 1000).toISOString(),
message_preview: stamp.message_text && stamp.message_text.length > 50 ? stamp.message_text.substring(0, 50) + '...' : stamp.message_text
}));
} catch (dataError) {
console.error('追加データ取得エラー:', dataError);
// エラーが発生しても、基本データは返す
}
// 最終的なユーザー情報を返す
res.json(user);
});
} catch (error) {
console.error('ユーザー詳細取得時のエラー:', error);
res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
}
});