diff --git a/commands/ping.js b/commands/ping.js index b8fb8a2..4bad057 100644 --- a/commands/ping.js +++ b/commands/ping.js @@ -1,5 +1,6 @@ -const { SlashCommandBuilder, EmbedBuilder, Colors } = require('discord.js'); +const { SlashCommandBuilder, Colors } = require('discord.js'); const os = require('os'); +const { createStandardEmbed } = require('../src/utils/embedBuilder'); module.exports = { data: new SlashCommandBuilder() @@ -8,71 +9,52 @@ module.exports = { async execute(interaction) { try { - // 初回応答時刻を記録 const startTime = Date.now(); - const sent = await interaction.reply({ content: '🏓 Pong! 詳細測定中...', fetchReply: true }); - // 各種遅延の計算 const endTime = Date.now(); const roundtripLatency = sent.createdTimestamp - interaction.createdTimestamp; const editLatency = endTime - startTime; const websocketLatency = Math.round(interaction.client.ws.ping); const apiLatency = Math.max(0, roundtripLatency - websocketLatency); - // システム情報の取得 const uptime = process.uptime(); const memUsage = process.memoryUsage(); const totalMem = os.totalmem(); const freeMem = os.freemem(); const usedMem = totalMem - freeMem; - // 遅延レベルの判定 function getLatencyLevel(ms) { - if (ms < 100) return { level: 'excellent', emoji: '🟢', color: Colors.Green, status: '優秀' }; - if (ms < 200) return { level: 'good', emoji: '🟡', color: Colors.Yellow, status: '良好' }; - if (ms < 500) return { level: 'fair', emoji: '🟠', color: Colors.Orange, status: '普通' }; - return { level: 'poor', emoji: '🔴', color: Colors.Red, status: '遅延' }; + if (ms < 100) return { emoji: '🟢', color: Colors.Green, status: '優秀' }; + if (ms < 200) return { emoji: '🟡', color: Colors.Yellow, status: '良好' }; + if (ms < 500) return { emoji: '🟠', color: Colors.Orange, status: '普通' }; + return { emoji: '🔴', color: Colors.Red, status: '遅延' }; } const wsLatencyInfo = getLatencyLevel(websocketLatency); const rtLatencyInfo = getLatencyLevel(roundtripLatency); + const overallInfo = getLatencyLevel(Math.max(websocketLatency, roundtripLatency)); - // 全体的な接続状態の判定 - const overallLatency = Math.max(websocketLatency, roundtripLatency); - const overallInfo = getLatencyLevel(overallLatency); - - // 時間フォーマット関数 function formatUptime(seconds) { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - - let result = ''; - if (days > 0) result += `${days}日 `; - if (hours > 0) result += `${hours}時間 `; - if (minutes > 0) result += `${minutes}分 `; - result += `${secs}秒`; - - return result; + return `${days > 0 ? days + '日 ' : ''}${hours > 0 ? hours + '時間 ' : ''}${minutes > 0 ? minutes + '分 ' : ''}${secs}秒`; } - // メモリ使用量フォーマット function formatBytes(bytes) { - const mb = bytes / 1024 / 1024; - return `${mb.toFixed(1)}MB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; } - // メイン埋め込み - const embed = new EmbedBuilder() - .setColor(overallInfo.color) - .setTitle(`${overallInfo.emoji} Pong! 接続状態: ${overallInfo.status}`) - .setDescription('🚀 **最新技術搭載Discord Bot** の詳細ステータス') - .addFields([ + const embed = createStandardEmbed({ + title: `${overallInfo.emoji} Pong! 接続状態: ${overallInfo.status}`, + description: '🚀 **最新技術搭載Discord Bot** の詳細ステータス', + color: overallInfo.color, + fields: [ { name: '📡 接続遅延情報', value: [ @@ -105,82 +87,17 @@ module.exports = { ].join('\n'), inline: false } - ]) - .setFooter({ - text: `実行者: ${interaction.user.tag} | 測定完了時刻`, + ], + footer: { + text: `実行者: ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() - }) - .setTimestamp(); - - // パフォーマンス評価 - let performanceNote = ''; - if (overallLatency < 100) { - performanceNote = '🚀 **素晴らしい接続状態です!** 全ての機能が高速で動作します。'; - } else if (overallLatency < 200) { - performanceNote = '✅ **良好な接続状態です。** 快適にご利用いただけます。'; - } else if (overallLatency < 500) { - performanceNote = '⚠️ **接続にやや遅延があります。** 機能は正常に動作します。'; - } else { - performanceNote = '🔴 **接続遅延が発生しています。** Discord側の問題の可能性があります。'; - } - - embed.addFields([ - { - name: '📊 パフォーマンス評価', - value: performanceNote, - inline: false } - ]); - - // リアルタイムステータス - const connectionStatus = interaction.client.ws.status; - const statusMap = { - 0: '🟢 Ready (準備完了)', - 1: '🟡 Connecting (接続中)', - 2: '🟠 Reconnecting (再接続中)', - 3: '🔴 Idle (待機中)', - 4: '⚫ Nearly (ほぼ切断)', - 5: '❌ Disconnected (切断済み)', - 6: '🔄 Waiting for Guilds (ギルド待機)', - 7: '🔄 Identifying (認証中)', - 8: '🔄 Resuming (再開中)' - }; - - embed.addFields([ - { - name: '🔌 接続ステータス', - value: `${statusMap[connectionStatus] || '❓ 不明'} (コード: ${connectionStatus})`, - inline: true - }, - { - name: '🕐 測定日時', - value: ``, - inline: true - } - ]); - - await interaction.editReply({ - content: '', - embeds: [embed] }); - // コンソールログ - console.log(`🏓 Ping測定完了: WS=${websocketLatency}ms, RT=${roundtripLatency}ms | ユーザー: ${interaction.user.tag}`); - + await interaction.editReply({ content: '', embeds: [embed] }); } catch (error) { console.error('Ping コマンドエラー:', error); - - const errorEmbed = new EmbedBuilder() - .setColor(Colors.Red) - .setTitle('❌ エラーが発生しました') - .setDescription('Ping測定中にエラーが発生しました。しばらくしてから再度お試しください。') - .setTimestamp(); - - if (interaction.deferred || interaction.replied) { - await interaction.editReply({ embeds: [errorEmbed] }).catch(() => {}); - } else { - await interaction.reply({ embeds: [errorEmbed], ephemeral: true }).catch(() => {}); - } + await interaction.editReply({ content: '❌ エラーが発生しました。' }).catch(() => {}); } }, -}; \ No newline at end of file +}; diff --git a/commands/profile-card.js b/commands/profile-card.js index fb99854..403a1db 100644 --- a/commands/profile-card.js +++ b/commands/profile-card.js @@ -16,7 +16,7 @@ try { // --- ヘルパー関数 --- -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; +const { calculateRequiredXp } = require('../src/services/levelingService'); function formatDuration(milliseconds) { if (milliseconds < 60000) { diff --git a/commands/rank-card.js b/commands/rank-card.js index 9e0c4ed..11bbf8a 100644 --- a/commands/rank-card.js +++ b/commands/rank-card.js @@ -13,7 +13,7 @@ try { console.error("フォントの読み込みに失敗しました。`fonts`ディレクトリに指定のフォントファイルがあるか確認してください。"); } -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; +const { calculateRequiredXp } = require('../src/services/levelingService'); // 角丸の四角形を描画するヘルパー関数 function roundRect(ctx, x, y, width, height, radius) { diff --git a/commands/rank.js b/commands/rank.js index d3f43d0..136d882 100644 --- a/commands/rank.js +++ b/commands/rank.js @@ -1,8 +1,6 @@ -const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); -const { getFirestore, collection, query, where, orderBy, limit, getDocs, doc, getDoc } = require('firebase/firestore'); - -// レベルアップに必要なXPを計算する関数 -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; +const { SlashCommandBuilder } = require('discord.js'); +const { getLevelData, getRank, calculateRequiredXp } = require('../src/services/levelingService'); +const { createStandardEmbed } = require('../src/utils/embedBuilder'); module.exports = { data: new SlashCommandBuilder() @@ -20,143 +18,40 @@ module.exports = { const guildId = interaction.guild.id; try { - // ユーザーデータの取得 - const userRef = doc(db, 'levels', `${guildId}_${targetUser.id}`); - const userSnap = await getDoc(userRef); - - if (!userSnap.exists()) { - return interaction.editReply({ - content: `📊 ${targetUser.username} さんにはまだランクデータがありません。\nメッセージを送信するとランクが記録されます。` - }); - } - - const rawData = userSnap.data(); - const userData = { - level: rawData.level || 0, - xp: rawData.xp || 0, - messageCount: rawData.messageCount || 0, - userId: rawData.userId, - guildId: rawData.guildId - }; - + const userData = await getLevelData(db, guildId, targetUser.id); + const rank = await getRank(db, guildId, targetUser.id); const requiredXp = calculateRequiredXp(userData.level); - // ランキングの取得 - const usersRef = collection(db, 'levels'); - const q = query( - usersRef, - where('guildId', '==', guildId), - orderBy('level', 'desc'), - orderBy('xp', 'desc') - ); - const snapshot = await getDocs(q); - - let rank = -1; - snapshot.docs.forEach((doc, index) => { - if (doc.data().userId === targetUser.id) { - rank = index + 1; - } - }); - - // 進捗バーの計算(修正版) let progress = 0; let progressPercentage = 0; - if (requiredXp > 0) { - // XPが必要値を超える場合も100%として扱う progressPercentage = Math.min((userData.xp / requiredXp) * 100, 100); - // 進捗バーは0〜10の範囲で表示 progress = Math.min(Math.floor((userData.xp / requiredXp) * 10), 10); } - const progressBar = '🟩'.repeat(progress) + '⬛'.repeat(10 - progress); - // メンバー情報の取得 const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null); const displayName = member ? member.displayName : targetUser.username; const avatarColor = member ? member.displayHexColor : '#5865F2'; - // Embedの作成 - const embed = new EmbedBuilder() - .setColor(avatarColor) - .setTitle(`🏆 ${displayName} のランク`) - .setThumbnail(targetUser.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { - name: '📊 レベル', - value: `**Lv.${userData.level}**`, - inline: true - }, - { - name: '🎖️ 順位', - value: rank !== -1 ? `**#${rank}**` : '計測中...', - inline: true - }, - { - name: '💬 総メッセージ数', - value: `**${userData.messageCount.toLocaleString()}** 回`, - inline: true - }, - { - name: '✨ 経験値 (XP)', - value: `**${userData.xp.toLocaleString()}** / ${requiredXp.toLocaleString()} XP`, - inline: false - }, - { - name: '📈 次のレベルへの進捗', - value: `${progressBar} **${progressPercentage.toFixed(1)}%**`, - inline: false - } - ) - .setFooter({ - text: `${interaction.guild.name} のランキング`, - iconURL: interaction.guild.iconURL() - }) - .setTimestamp(); + const embed = createStandardEmbed({ + title: `🏆 ${displayName} のランク`, + color: avatarColor, + thumbnail: targetUser.displayAvatarURL({ dynamic: true, size: 256 }), + fields: [ + { name: '📊 レベル', value: `**Lv.${userData.level}**`, inline: true }, + { name: '🎖️ 順位', value: rank !== -1 ? `**#${rank}**` : '計測中...', inline: true }, + { name: '💬 総メッセージ数', value: `**${(userData.messageCount || 0).toLocaleString()}** 回`, inline: true }, + { name: '✨ 経験値 (XP)', value: `**${Math.floor(userData.xp).toLocaleString()}** / ${requiredXp.toLocaleString()} XP`, inline: false }, + { name: '📈 次のレベルへの進捗', value: `${progressBar} **${progressPercentage.toFixed(1)}%**`, inline: false } + ], + footer: { text: interaction.guild.name, iconURL: interaction.guild.iconURL() } + }); await interaction.editReply({ embeds: [embed] }); - } catch (error) { console.error('❌ ランクコマンドの実行エラー:', error); - console.error('エラー詳細:', error.message); - console.error('エラーコード:', error.code); - - // エラーコード別の詳細な対応 - if (error.code === 'failed-precondition') { - await interaction.editReply({ - content: '❌ **データベースインデックスエラー**\n\n' + - '⚠️ ランキング機能に必要なFirestoreインデックスが作成されていません。\n\n' + - '**管理者向け手順:**\n' + - '1. コンソールログに表示されているURLにアクセス\n' + - '2. Firebaseコンソールでインデックスを作成\n' + - '3. インデックス作成完了まで数分お待ちください\n\n' + - '詳細: https://firebase.google.com/docs/firestore/query-data/indexing' - }); - } else if (error.code === 'permission-denied') { - await interaction.editReply({ - content: '❌ **権限エラー**\n\n' + - 'データベースへのアクセス権限がありません。\n' + - 'Firestoreのセキュリティルールを確認してください。' - }); - } else if (error.code === 'unavailable') { - await interaction.editReply({ - content: '❌ **接続エラー**\n\n' + - 'データベースに接続できませんでした。\n' + - 'しばらく時間をおいてから再度お試しください。' - }); - } else if (error.code === 'not-found') { - await interaction.editReply({ - content: '❌ **データ取得エラー**\n\n' + - '指定されたデータが見つかりませんでした。' - }); - } else { - await interaction.editReply({ - content: '❌ **予期しないエラーが発生しました**\n\n' + - 'ランク情報の取得中に問題が発生しました。\n' + - 'Bot管理者に連絡してください。\n\n' + - `エラーコード: \`${error.code || 'UNKNOWN'}\`` - }); - } + await interaction.editReply({ content: '❌ ランク情報の取得中にエラーが発生しました。' }); } } -}; \ No newline at end of file +}; diff --git a/events/guildMemberAdd.js b/events/guildMemberAdd.js index ee9e594..6a1f0fa 100644 --- a/events/guildMemberAdd.js +++ b/events/guildMemberAdd.js @@ -1,59 +1,8 @@ // ===== guildMemberAdd.js ===== -const { EmbedBuilder, PermissionsBitField } = require('discord.js'); +const { PermissionsBitField } = require('discord.js'); const { doc, getDoc, setDoc } = require('firebase/firestore'); - -// ★★★★★【ここから追加・変更】★★★★★ -// Geminiでウェルカムメッセージを生成する関数 -async function generateWelcomeWithGemini(client, member) { - const { user, guild } = member; - try { - const prompt = `あなたはDiscordサーバーの歓迎担当AIです。新しく参加したユーザーを温かく、そしてクリエイティブに歓迎するメッセージを作成してください。 - -# 指示 -- ポジティブで、歓迎の意が伝わるフレンドリーな文章を生成してください。 -- 以下の情報を文章に必ず含めてください。 - - ユーザー名: ${user.displayName} - - サーバー名: ${guild.name} - - 現在のメンバー数: ${guild.memberCount} -- 生成する文章は必ず**タイトル**と**説明文**の2つの部分に分けてください。 -- タイトルは「🎉」や「ようこそ!」などの絵文字を含んだ短いフレーズにしてください。(20文字以内) -- 説明文は、ユーザーへの呼びかけから始まり、サーバーの簡単な紹介や、これから始まる素晴らしい体験への期待感を抱かせるような、少し長めの文章にしてください。(150文字以内) -- 必ずJSON形式で、{"title": "生成したタイトル", "description": "生成した説明文"} の形式で出力してください。 - -# 生成例 -{ - "title": "🎉 新たな仲間が参加しました!", - "description": "${user.displayName}さん、ようこそ!${guild.name}の${guild.memberCount}人目のメンバーとして、あなたを心から歓迎します。ここではたくさんの素晴らしい出会いと楽しい時間が待っていますよ!" -}`; - - const result = await client.geminiModel.generateContent(prompt); - const text = result.response.text().replace(/```json|```/g, '').trim(); - return JSON.parse(text); - } catch (error) { - console.error('❌ Geminiでのウェルカムメッセージ生成エラー:', error); - // フォールバック - return { - title: `🎉 ${guild.name}へようこそ!`, - description: `**${user.displayName}**さん、サーバーへのご参加ありがとうございます!これから一緒に楽しみましょう!` - }; - } -} - -// テキスト内の変数を置換する関数 -function replacePlaceholders(text, member, config) { - const { user, guild } = member; - const rulesChannel = config.rulesChannelId ? `<#${config.rulesChannelId}>` : 'ルールチャンネル'; - - return text - .replace(/{user.name}/g, user.username) - .replace(/{user.tag}/g, user.tag) - .replace(/{user.displayName}/g, user.displayName) - .replace(/{user.mention}/g, `<@${user.id}>`) - .replace(/{server.name}/g, guild.name) - .replace(/{server.memberCount}/g, guild.memberCount.toLocaleString()) - .replace(/{rulesChannel}/g, rulesChannel); -} -// ★★★★★【ここまで追加・変更】★★★★★ +const { generateWelcomeWithGemini, replacePlaceholders } = require('../src/services/welcomeService'); +const { createStandardEmbed } = require('../src/utils/embedBuilder'); module.exports = { @@ -133,20 +82,17 @@ module.exports = { title = generated.title; description = generated.description; } else { - title = replacePlaceholders(welcomeMsgConfig.title, member, guildConfig); - description = replacePlaceholders(welcomeMsgConfig.description, member, guildConfig); + title = replacePlaceholders(welcomeMsgConfig.title, member, guildConfig.rulesChannelId); + description = replacePlaceholders(welcomeMsgConfig.description, member, guildConfig.rulesChannelId); } - const welcomeEmbed = new EmbedBuilder() - .setColor(0x00ff00) - .setTitle(title) - .setDescription(description) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) - .setTimestamp(); - - if (welcomeMsgConfig.imageUrl) { - welcomeEmbed.setImage(welcomeMsgConfig.imageUrl); - } + const welcomeEmbed = createStandardEmbed({ + color: 0x00ff00, + title: title, + description: description, + thumbnail: user.displayAvatarURL({ dynamic: true, size: 256 }), + image: welcomeMsgConfig.imageUrl || null + }); await welcomeChannel.send({ content: guildConfig.mentionOnWelcome ? `<@${user.id}>` : null, @@ -156,12 +102,12 @@ module.exports = { console.log(`💌 ${user.tag} のカスタムウェルカムメッセージを送信しました`); } else { - // --- 従来のウェルカムメッセージ(フォールバック) --- - const welcomeEmbed = new EmbedBuilder() - .setColor(0x00ff00) - .setTitle(`🎉 ${member.guild.name} へようこそ!`) - .setDescription(`**${user.displayName}** さん、サーバーへのご参加ありがとうございます!`) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })); + const welcomeEmbed = createStandardEmbed({ + color: 0x00ff00, + title: `🎉 ${member.guild.name} へようこそ!`, + description: `**${user.displayName}** さん、サーバーへのご参加ありがとうございます!`, + thumbnail: user.displayAvatarURL({ dynamic: true, size: 256 }) + }); if (guildConfig.rulesChannelId) { const rulesChannel = member.guild.channels.cache.get(guildConfig.rulesChannelId); diff --git a/events/interactionCreate.js b/events/interactionCreate.js index b7b10d6..b5dc439 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -1,4 +1,5 @@ -// systemcmd0122/orderlycore/OrderlyCore-0952a29494b13fadb3d53fc470ecdb1ede3f7840/events/interactionCreate.js +const chalk = require('chalk'); + module.exports = { name: 'interactionCreate', async execute(interaction, client) { @@ -9,7 +10,7 @@ module.exports = { if (interaction.isChatInputCommand()) { const command = client.commands.get(interaction.commandName); if (!command) { - console.error(`❌ 未知のコマンド: ${interaction.commandName}`); + console.error(chalk.red(`❌ Unknown command: ${interaction.commandName}`)); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: `❌ コマンド「${interaction.commandName}」が見つかりません。`, @@ -20,17 +21,16 @@ module.exports = { } try { - console.log(`🎯 コマンド実行: /${interaction.commandName} | ユーザー: ${interaction.user.tag} | サーバー: ${interaction.guild?.name || 'DM'}`); + console.log(chalk.cyan(`🎯 Command Execution: /${interaction.commandName} | User: ${interaction.user.tag} | Guild: ${interaction.guild?.name || 'DM'}`)); await command.execute(interaction); } catch (error) { - console.error(`❌ コマンド実行エラー (${interaction.commandName}):`, error); - // ★★★★★【ここから修正】★★★★★ - // ephemeralメッセージの送信方法を修正 + console.error(chalk.red(`❌ Command Execution Error (${interaction.commandName}):`), error); + const errorMessage = { content: '⚠️ コマンドの実行中にエラーが発生しました。しばらく時間をおいてから再度お試しください。', ephemeral: true }; - // ★★★★★【ここまで修正】★★★★★ + try { if (interaction.replied || interaction.deferred) { await interaction.followUp(errorMessage); @@ -38,7 +38,7 @@ module.exports = { await interaction.reply(errorMessage); } } catch (responseError) { - console.error('❌ エラーレスポンス送信失敗:', responseError); + console.error(chalk.red('❌ Failed to send error response:'), responseError); } } return; @@ -51,13 +51,13 @@ module.exports = { try { await command.autocomplete(interaction); } catch (error) { - console.error(`❌ オートコンプリートエラー (${interaction.commandName}):`, error); + console.error(chalk.red(`❌ Autocomplete Error (${interaction.commandName}):`), error); } return; } if (interaction.isModalSubmit()) { - // 他のモーダル処理が必要な場合はここに追加 + // Add modal submit handling here if needed } } -}; \ No newline at end of file +}; diff --git a/events/levelingSystem.js b/events/levelingSystem.js index 2311609..14991cb 100644 --- a/events/levelingSystem.js +++ b/events/levelingSystem.js @@ -1,135 +1,24 @@ -const { Events, EmbedBuilder, PermissionsBitField } = require('discord.js'); -const { doc, getDoc, setDoc, collection, query, where, orderBy, getDocs } = require('firebase/firestore'); +const { Events, PermissionsBitField } = require('discord.js'); +const { doc, getDoc, setDoc } = require('firebase/firestore'); const chalk = require('chalk'); +const { getLevelData, getRank, calculateRequiredXp, generateLevelUpComment, handleRoleRewards } = require('../src/services/levelingService'); +const { createStandardEmbed } = require('../src/utils/embedBuilder'); -// レベルアップに必要なXPを計算する関数 -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; - -// Gemini AIにレベルアップコメントを生成させる関数 (修正済み) -async function generateLevelUpComment(client, user, newLevel, serverName) { - try { - const prompt = `あなたはDiscordサーバーの優秀なアシスタントです。以下の指示に従って、ユーザーのレベルアップを祝福するメッセージを**一行で**生成してください。 - -### 指示 -* **役割**: ユーザーの功績を称え、今後の活躍を期待させるような、ユニークでクリエイティブなメッセージを作成します。 -* **トーン**: 非常にポジティブで、少し壮大な雰囲気にしてください。 -* **必須要素**: - * ユーザー名: ${user.displayName} - * 新しいレベル: ${newLevel} - * サーバー名: ${serverName} -* **厳格な制約**: - * 生成する文章は**必ず一行**にしてください。 - * **80文字以内**に収めてください。 - * 毎回必ず違うパターンの文章を生成してください。 - * **回答には祝福メッセージのみを含め、それ以外の前置き、解説、リスト、引用符(「」)は絶対に含めないでください。** - -### 生成例 -* ${serverName}の歴史に名を刻む時が来た!${user.displayName}よ、レベル${newLevel}への到達、誠におめでとう! -* 天晴れ!${user.displayName}の活躍により${serverName}は新たな時代へ。伝説はレベル${newLevel}から始まる! -* ${serverName}に新たな光が灯った!${user.displayName}、レベル${newLevel}への昇格、心より祝福する。`; - - const result = await client.geminiModel.generateContent(prompt); - // 不要な文字を除去する処理を強化 - const text = result.response.text().trim().replace(/[\n*「」]/g, '').split('。')[0]; - console.log(chalk.magenta(`[Gemini] Generated comment: ${text}`)); - return text; - } catch (error) { - console.error(chalk.red('❌ Gemini APIでのコメント生成に失敗:'), error.message); - return `**${user.displayName} が新たな境地へ到達しました!**\n絶え間ない努力が実を結び、サーバー内での存在感がさらに増しました。`; - } -} - -// ユーザーデータを取得または新規作成する関数 -async function getLevelData(db, guildId, userId) { - const userRef = doc(db, 'levels', `${guildId}_${userId}`); - const docSnap = await getDoc(userRef); - if (docSnap.exists()) { - const data = docSnap.data(); - if (typeof data.level === 'undefined') { - data.level = 0; - } - return data; - } - return { - guildId, - userId, - xp: 0, - level: 0, - messageCount: 0, - lastMessageTimestamp: 0 - }; -} - -// ロール報酬を処理する関数 -async function handleRoleRewards(member, oldLevel, newLevel, settings) { - const levelingSettings = settings.leveling || {}; - const roleRewards = levelingSettings.roleRewards || []; - if (roleRewards.length === 0) return; - - // 付与すべきロールを特定 - const rewardsToGive = roleRewards - .filter(reward => reward.level > oldLevel && reward.level <= newLevel) - .sort((a, b) => a.level - b.level); - - if (rewardsToGive.length === 0) return; - - // ボットの権限チェック - if (!member.guild.members.me.permissions.has(PermissionsBitField.Flags.ManageRoles)) { - console.error(chalk.red(`[Role Reward] Bot does not have Manage Roles permission in ${member.guild.name}.`)); - return; - } - - let awardedRoles = []; - for (const reward of rewardsToGive) { - try { - const role = member.guild.roles.cache.get(reward.roleId); - if (!role) { - console.warn(chalk.yellow(`[Role Reward] Role ID ${reward.roleId} for level ${reward.level} not found.`)); - continue; - } - - // ロール階層チェック - if (role.position >= member.guild.members.me.roles.highest.position) { - console.warn(chalk.yellow(`[Role Reward] Cannot assign role ${role.name} as it is higher than or equal to the bot's role.`)); - continue; - } - - if (!member.roles.cache.has(role.id)) { - await member.roles.add(role); - awardedRoles.push(role); - console.log(chalk.green(`[Role Reward] Awarded role "${role.name}" to ${member.user.tag} for reaching level ${reward.level}.`)); - } - } catch (error) { - console.error(chalk.red(`[Role Reward] Failed to award role for level ${reward.level} to ${member.user.tag}:`), error); - } - } - return awardedRoles; -} - -// レベリング処理のメイン関数 async function handleMessage(message, client) { if (!message.guild || message.author.bot) return; const { guild, author, member } = message; const db = client.db; - const guildId = guild.id; - const userId = author.id; - const userData = await getLevelData(db, guildId, userId); - + const userData = await getLevelData(db, guild.id, author.id); const now = Date.now(); - if (now - (userData.lastMessageTimestamp || 0) < 60000) { - return; - } + if (now - (userData.lastMessageTimestamp || 0) < 60000) return; const xpGained = Math.floor(Math.random() * 11) + 15; - userData.xp += xpGained; userData.messageCount += 1; userData.lastMessageTimestamp = now; - console.log(chalk.cyan(`[XP] ${author.tag} gained ${xpGained} XP. New Total: ${Math.floor(userData.xp)}`)); - let leveledUp = false; const oldLevel = userData.level; let requiredXp = calculateRequiredXp(userData.level); @@ -141,12 +30,10 @@ async function handleMessage(message, client) { requiredXp = calculateRequiredXp(userData.level); } - const userRef = doc(db, 'levels', `${guildId}_${userId}`); + const userRef = doc(db, 'levels', `${guild.id}_${author.id}`); await setDoc(userRef, userData, { merge: true }); if (leveledUp) { - console.log(chalk.green(`[LEVEL UP] ${author.tag} reached level ${userData.level}!`)); - const settingsRef = doc(db, 'guild_settings', guild.id); const settingsSnap = await getDoc(settingsRef); const settings = settingsSnap.exists() ? settingsSnap.data() : {}; @@ -155,30 +42,19 @@ async function handleMessage(message, client) { if (settings.levelUpChannel) { const targetChannel = await client.channels.fetch(settings.levelUpChannel).catch(() => null); - if (targetChannel && targetChannel.isTextBased()) { - const awesomeComment = await generateLevelUpComment(client, author, userData.level, guild.name); - - const usersRef = collection(db, 'levels'); - const q = query(usersRef, where('guildId', '==', guildId), orderBy('level', 'desc'), orderBy('xp', 'desc')); - const snapshot = await getDocs(q); - let rank = -1; - snapshot.docs.forEach((doc, index) => { - if (doc.data().userId === userId) { - rank = index + 1; - } - }); + const comment = await generateLevelUpComment(client, author, userData.level, guild.name); + const rank = await getRank(db, guild.id, author.id); const progress = requiredXp > 0 ? Math.floor((userData.xp / requiredXp) * 20) : 0; const progressBar = `**[** ${'🟦'.repeat(progress)}${'⬛'.repeat(20 - progress)} **]**`; - const levelUpEmbed = new EmbedBuilder() - .setColor(0x00FFFF) - .setAuthor({ name: `LEVEL UP! - ${author.displayName}`, iconURL: author.displayAvatarURL() }) - .setTitle(`《 RANK UP: ${oldLevel} ➔ ${userData.level} 》`) - .setDescription(awesomeComment) - .setThumbnail(author.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( + const embed = createStandardEmbed({ + author: { name: `LEVEL UP! - ${author.displayName}`, iconURL: author.displayAvatarURL() }, + title: `《 RANK UP: ${oldLevel} ➔ ${userData.level} 》`, + description: comment, + thumbnail: author.displayAvatarURL({ dynamic: true, size: 256 }), + fields: [ { name: '📊 現在のステータス', value: `**サーバー内順位:** **${rank !== -1 ? `#${rank}` : 'N/A'}**\n**総メッセージ数:** **${userData.messageCount.toLocaleString()}** 回`, @@ -186,26 +62,22 @@ async function handleMessage(message, client) { }, { name: `🚀 次のレベルまで (Lv. ${userData.level + 1})`, - value: `あと **${(requiredXp - userData.xp).toLocaleString()}** XP\n${progressBar} **${Math.floor(userData.xp).toLocaleString()}** / **${requiredXp.toLocaleString()}**`, + value: `あと **${Math.floor(requiredXp - userData.xp).toLocaleString()}** XP\n${progressBar} **${Math.floor(userData.xp).toLocaleString()}** / **${requiredXp.toLocaleString()}**`, inline: false } - ) - .setFooter({ text: `偉業達成おめでとうございます! | ${guild.name}`, iconURL: guild.iconURL() }) - .setTimestamp(); + ], + footer: { text: `偉業達成おめでとうございます! | ${guild.name}`, iconURL: guild.iconURL() } + }); if (awardedRoles && awardedRoles.length > 0) { - levelUpEmbed.addFields({ + embed.addFields({ name: '🏆 獲得したロール報酬', value: awardedRoles.map(r => r.toString()).join('\n'), inline: false }); } - try { - await targetChannel.send({ embeds: [levelUpEmbed] }); - } catch (error) { - console.error(chalk.red('レベルアップ通知の送信に失敗しました:'), error); - } + await targetChannel.send({ embeds: [embed] }).catch(console.error); } } } @@ -213,4 +85,4 @@ async function handleMessage(message, client) { module.exports = (client) => { client.on(Events.MessageCreate, (message) => handleMessage(message, client)); -}; \ No newline at end of file +}; diff --git a/events/ready.js b/events/ready.js index 0a8c37e..1e75355 100644 --- a/events/ready.js +++ b/events/ready.js @@ -1,13 +1,27 @@ const { ActivityType } = require('discord.js'); +const chalk = require('chalk'); +const { startStatusRotation } = require('../src/services/statusService'); +const { updateRankboards } = require('../src/services/rankboardService'); +const { keepAlive } = require('../src/utils/helpers'); module.exports = { name: 'ready', once: true, - execute(client) { - console.log(`✅ ${client.user.tag} がオンラインになりました!`); + async execute(client) { + console.log(chalk.bold.greenBright(`🚀 ${client.user.tag} が起動しました!`)); - // 初期アクティビティ設定 + // Initial setup client.user.setActivity('🚀 起動中...', { type: ActivityType.Custom }); client.user.setStatus('online'); + + // Start services + keepAlive(); + startStatusRotation(client); + + // Rankboard updates + setTimeout(() => updateRankboards(client), 10000); + setInterval(() => updateRankboards(client), 5 * 60 * 1000); + + console.log(chalk.green('✅ Bot services initialized.')); } -}; \ No newline at end of file +}; diff --git a/events/voiceStateLog.js b/events/voiceStateLog.js index db2ef75..9d10126 100644 --- a/events/voiceStateLog.js +++ b/events/voiceStateLog.js @@ -1,7 +1,9 @@ -const { Events, EmbedBuilder, PermissionsBitField } = require('discord.js'); +const { Events, PermissionsBitField } = require('discord.js'); const chalk = require('chalk'); -const { getFirestore, doc, getDoc, setDoc, updateDoc, increment, collection, query, where, orderBy, getDocs } = require('firebase/firestore'); -const { getDatabase, ref, set, remove, get } = require('firebase/database'); +const { doc, getDoc, setDoc, increment, collection, query, where, orderBy, getDocs } = require('firebase/firestore'); +const { ref, set, remove, get } = require('firebase/database'); +const { getLevelData, getRank, calculateRequiredXp, generateLevelUpComment, handleRoleRewards } = require('../src/services/levelingService'); +const { createStandardEmbed } = require('../src/utils/embedBuilder'); class MessageDeleteManager { constructor() { @@ -31,93 +33,6 @@ class MessageDeleteManager { } const deleteManager = new MessageDeleteManager(); -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; - -// Gemini AIにレベルアップコメントを生成させる関数 (修正済み) -async function generateLevelUpComment(client, user, newLevel, serverName) { - try { - const prompt = `あなたはDiscordサーバーの優秀なアシスタントです。以下の指示に従って、ユーザーのレベルアップを祝福するメッセージを**一行で**生成してください。 - -### 指示 -* **役割**: ユーザーの功績を称え、今後の活躍を期待させるような、ユニークでクリエイティブなメッセージを作成します。 -* **トーン**: 非常にポジティブで、少し壮大な雰囲気にしてください。 -* **必須要素**: - * ユーザー名: ${user.displayName} - * 新しいレベル: ${newLevel} - * サーバー名: ${serverName} -* **厳格な制約**: - * 生成する文章は**必ず一行**にしてください。 - * **80文字以内**に収めてください。 - * 毎回必ず違うパターンの文章を生成してください。 - * **回答には祝福メッセージのみを含め、それ以外の前置き、解説、リスト、引用符(「」)は絶対に含めないでください。** - -### 生成例 -* ${serverName}の歴史に名を刻む時が来た!${user.displayName}よ、レベル${newLevel}への到達、誠におめでとう! -* 天晴れ!${user.displayName}の活躍により${serverName}は新たな時代へ。伝説はレベル${newLevel}から始まる! -* ${serverName}に新たな光が灯った!${user.displayName}、レベル${newLevel}への昇格、心より祝福する。`; - - const result = await client.geminiModel.generateContent(prompt); - // 不要な文字を除去する処理を強化 - const text = result.response.text().trim().replace(/[\n*「」]/g, '').split('。')[0]; - console.log(chalk.magenta(`[Gemini] Generated comment for VC Level Up: ${text}`)); - return text; - } catch (error) { - console.error(chalk.red('❌ Gemini APIでのコメント生成に失敗:'), error.message); - return `**${user.displayName} が新たな境地へ到達しました!**\n絶え間ない努力が実を結び、サーバー内での存在感がさらに増しました。`; - } -} - -async function getLevelData(db, guildId, userId) { - const userRef = doc(db, 'levels', `${guildId}_${userId}`); - const docSnap = await getDoc(userRef); - if (docSnap.exists()) { - const data = docSnap.data(); - if (typeof data.level === 'undefined') data.level = 0; - return data; - } - return { guildId, userId, xp: 0, level: 0, messageCount: 0, lastMessageTimestamp: 0 }; -} - -// levelingSystem.js と同じロール報酬処理関数 -async function handleRoleRewards(member, oldLevel, newLevel, settings) { - const levelingSettings = settings.leveling || {}; - const roleRewards = levelingSettings.roleRewards || []; - if (roleRewards.length === 0) return; - - const rewardsToGive = roleRewards - .filter(reward => reward.level > oldLevel && reward.level <= newLevel) - .sort((a, b) => a.level - b.level); - - if (rewardsToGive.length === 0) return; - - if (!member.guild.members.me.permissions.has(PermissionsBitField.Flags.ManageRoles)) { - console.error(chalk.red(`[Role Reward] Bot does not have Manage Roles permission in ${member.guild.name}.`)); - return; - } - - let awardedRoles = []; - for (const reward of rewardsToGive) { - try { - const role = member.guild.roles.cache.get(reward.roleId); - if (!role) { - console.warn(chalk.yellow(`[Role Reward] Role ID ${reward.roleId} for level ${reward.level} not found.`)); - continue; - } - if (role.position >= member.guild.members.me.roles.highest.position) { - console.warn(chalk.yellow(`[Role Reward] Cannot assign role ${role.name} as it is higher than or equal to the bot's role.`)); - continue; - } - if (!member.roles.cache.has(role.id)) { - await member.roles.add(role); - awardedRoles.push(role); - console.log(chalk.green(`[Role Reward] Awarded role "${role.name}" to ${member.user.tag} for reaching level ${reward.level} (VC).`)); - } - } catch (error) { - console.error(chalk.red(`[Role Reward] Failed to award role for level ${reward.level} to ${member.user.tag} (VC):`), error); - } - } - return awardedRoles; -} async function getLogChannelIdForVc(db, guildId, voiceChannelId) { if (!guildId || !voiceChannelId) return null; @@ -193,26 +108,26 @@ async function addVcExpAndLevelUp(client, oldState, stayDuration) { const progress = requiredXp > 0 ? Math.floor((userData.xp / requiredXp) * 20) : 0; const progressBar = `**[** ${'🟦'.repeat(progress)}${'⬛'.repeat(20 - progress)} **]**`; - const levelUpEmbed = new EmbedBuilder() - .setColor(0x00FFFF) - .setAuthor({ name: `LEVEL UP! (VC) - ${member.displayName}`, iconURL: member.user.displayAvatarURL() }) - .setTitle(`《 RANK UP: ${oldLevel} ➔ ${userData.level} 》`) - .setDescription(awesomeComment) - .setThumbnail(member.user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( + const levelUpEmbed = createStandardEmbed({ + color: 0x00FFFF, + author: { name: `LEVEL UP! (VC) - ${member.displayName}`, iconURL: member.user.displayAvatarURL() }, + title: `《 RANK UP: ${oldLevel} ➔ ${userData.level} 》`, + description: awesomeComment, + thumbnail: member.user.displayAvatarURL({ dynamic: true, size: 256 }), + fields: [ { name: '📊 現在のステータス', - value: `**サーバー内順位:** **${rank !== -1 ? `#${rank}` : 'N/A'}**\n**総メッセージ数:** **${userData.messageCount.toLocaleString()}** 回`, + value: `**サーバー内順位:** **${rank !== -1 ? `#${rank}` : 'N/A'}**\n**総メッセージ数:** **${(userData.messageCount || 0).toLocaleString()}** 回`, inline: false }, { name: `🚀 次のレベルまで (Lv. ${userData.level + 1})`, - value: `あと **${(requiredXp - userData.xp).toLocaleString()}** XP\n${progressBar} **${userData.xp.toLocaleString()}** / **${requiredXp.toLocaleString()}**`, + value: `あと **${Math.floor(requiredXp - userData.xp).toLocaleString()}** XP\n${progressBar} **${Math.floor(userData.xp).toLocaleString()}** / **${requiredXp.toLocaleString()}**`, inline: false } - ) - .setFooter({ text: `ボイスチャンネルでの活動、お疲れ様です! | ${guild.name}`, iconURL: guild.iconURL() }) - .setTimestamp(); + ], + footer: { text: `ボイスチャンネルでの活動、お疲れ様です! | ${guild.name}`, iconURL: guild.iconURL() } + }); if (awardedRoles && awardedRoles.length > 0) { levelUpEmbed.addFields({ diff --git a/index.js b/index.js index 38e5e4e..b3eee9d 100644 --- a/index.js +++ b/index.js @@ -1,42 +1,48 @@ +/** + * OrderlyCore - Production Grade Discord Bot + * + * Main entry point for the application. + * Responsibility: Initialize configuration, express server, and Discord bot. + */ + require('dotenv').config(); -const { Client, GatewayIntentBits, Collection, REST, Routes, ActivityType, Partials, PermissionsBitField, EmbedBuilder, PresenceUpdateStatus } = require('discord.js'); -const fs = require('node:fs'); -const path = require('node:path'); const express = require('express'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const cors = require('cors'); +const path = require('node:path'); +const fs = require('node:fs'); const chalk = require('chalk'); -const { GoogleGenerativeAI } = require('@google/generative-ai'); -const { initializeApp } = require('firebase/app'); -const { getFirestore, doc, getDoc, setDoc, collection, query, where, getDocs, updateDoc, deleteDoc, Timestamp, orderBy, limit, startAfter, endBefore, getCountFromServer } = require('firebase/firestore'); -const { getDatabase, ref, set, get, remove } = require('firebase/database'); -const os = require('os'); + +const { validateEnv, PORT, NODE_ENV, FRONTEND_URL } = require('./src/config/env'); +const { client, loadCommands, loadEvents } = require('./src/config/discord'); + +// 1. Validate Environment Variables +validateEnv(); const app = express(); -const PORT = process.env.PORT || 8000; +// 2. Express Middleware Setup app.use(cors({ - origin: [process.env.FRONTEND_URL, 'http://localhost:3000'].filter(Boolean), + origin: [FRONTEND_URL, 'http://localhost:3000'].filter(Boolean), credentials: true })); - app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(session({ - secret: process.env.SESSION_SECRET || 'your_very_secret_key_that_is_long_and_random', + secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true, cookie: { - secure: process.env.NODE_ENV === 'production', + secure: NODE_ENV === 'production', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } })); app.use(express.static(path.join(__dirname, 'public'))); - +// 3. Security Headers Middleware app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); @@ -44,1208 +50,71 @@ app.use((_req, res, next) => { next(); }); -// --- Firebase設定 --- -const firebaseConfig = { - apiKey: process.env.FIREBASE_API_KEY, - authDomain: process.env.FIREBASE_AUTH_DOMAIN, - databaseURL: process.env.FIREBASE_DATABASE_URL, - projectId: process.env.FIREBASE_PROJECT_ID, - storageBucket: process.env.FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.FIREBASE_APP_ID, - measurementId: process.env.FIREBASE_MEASUREMENT_ID -}; -const firebaseApp = initializeApp(firebaseConfig); -const db = getFirestore(firebaseApp); -const rtdb = getDatabase(firebaseApp); +// 4. API & Authentication Routes +app.use('/api', require('./src/routes/auth')); +app.use('/api', require('./src/routes/api')); +app.use('/api/admin', require('./src/routes/admin')); -const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY); -const geminiModel = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildVoiceStates, - GatewayIntentBits.GuildMessageReactions - ], - partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.GuildMember] -}); - -client.db = db; -client.rtdb = rtdb; -client.commands = new Collection(); -client.geminiModel = geminiModel; - -let dynamicStatuses = []; -let statusInterval = null; -let statusMode = 'ai'; - - -const isAuthenticated = (req, res, next) => { - if (req.session.userId && req.session.guildId) { - return next(); - } - res.status(401).json({ error: 'Unauthorized. Please login again.' }); -}; - -const isGuildAdmin = async (req, res, next) => { - try { - const guild = await client.guilds.fetch(req.session.guildId); - const member = await guild.members.fetch(req.session.userId); - if (member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return next(); - } - res.status(403).json({ error: 'Forbidden: You are not an administrator of this server.' }); - } catch (error) { - console.error('Error checking guild admin status:', error); - res.status(500).json({ error: 'Internal Server Error while verifying permissions.' }); - } -}; - -const isAdminAuthenticated = (req, res, next) => { - if (req.session.isAdmin) { - return next(); - } - res.status(401).json({ error: 'Administrator access required.' }); -}; - -app.get('/ping', (_req, res) => { - res.status(200).json({ - status: 'ok', - timestamp: new Date().toISOString() - }); -}); +// 5. Basic Health & Static Routes app.get('/health', (_req, res) => { - const health = { + res.status(client.isReady() ? 200 : 503).json({ status: client.isReady() ? 'ok' : 'degraded', timestamp: new Date().toISOString(), uptime: process.uptime(), - memory: process.memoryUsage(), - bot_status_code: client.ws.status - }; - res.status(client.isReady() ? 200 : 503).json(health); -}); - -function keepAlive() { - const PING_INTERVAL = 2 * 60 * 1000; - const appUrl = process.env.APP_URL; - - if (!appUrl) { - console.warn(chalk.yellow('⚠️ APP_URLが設定されていません。Keep-alive機能は無効になります。')); - return; - } - - setInterval(async () => { - try { - const url = appUrl.endsWith('/ping') ? appUrl : `${appUrl}/ping`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - const response = await fetch(url, { - signal: controller.signal, - headers: { 'User-Agent': `OrderlyCore-Bot/${require('./package.json').version}` } - }); - - clearTimeout(timeout); - - if (response.ok) { - console.log(chalk.blueBright(`[Keep-Alive] Ping successful to ${url}. Status: ${response.status}`)); - } else { - console.error(chalk.yellow(`[Keep-Alive] Ping failed to ${url}. Status: ${response.status}`)); - } - } catch (error) { - console.error(chalk.red(`[Keep-Alive] Error pinging ${appUrl}:`, error.message)); - } - }, PING_INTERVAL); -} - -app.post('/api/verify', async (req, res) => { - const { token } = req.body; - if (!token) return res.status(400).json({ error: 'Token is required.' }); - - const tokenRef = ref(rtdb, `authTokens/${token}`); - try { - const snapshot = await get(tokenRef); - if (snapshot.exists()) { - const data = snapshot.val(); - if (data.expiresAt > Date.now()) { - req.session.userId = data.userId; - req.session.guildId = data.guildId; - await remove(tokenRef); - return res.status(200).json({ message: 'Login successful!', guildId: data.guildId }); - } else { - await remove(tokenRef); - return res.status(401).json({ error: 'Token has expired.' }); - } - } else { - return res.status(401).json({ error: 'Invalid token.' }); - } - } catch (error) { - console.error("Token verification error:", error); - return res.status(500).json({ error: 'Database error during token verification.' }); - } -}); - -app.post('/api/logout', (req, res) => { - req.session.destroy(err => { - if (err) { - return res.status(500).json({ error: 'Could not log out.' }); - } - res.clearCookie('connect.sid'); - res.status(200).json({ message: 'Logged out successfully.' }); + memory: process.memoryUsage() }); }); -app.get('/api/guild-info', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guild = await client.guilds.fetch(req.session.guildId); - const channels = guild.channels.cache - .filter(c => c.type === 0 || c.type === 2) - .map(c => ({ id: c.id, name: c.name, type: c.type })) - .sort((a, b) => a.name.localeCompare(b.name)); - - const roles = guild.roles.cache - .filter(r => r.id !== guild.id) - .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) - .sort((a,b) => a.name.localeCompare(b.name)); - - // キャッシュされたメンバー情報を使用(タイムアウトを避けるため) - let memberCount = 0; - let botCount = 0; - - if (guild.members.cache.size > 0) { - // キャッシュがある場合はそれを使用 - memberCount = guild.members.cache.filter(member => !member.user.bot).size; - botCount = guild.members.cache.size - memberCount; - } else { - // キャッシュがない場合はguild.memberCountを使用(概算) - memberCount = guild.memberCount || 0; - botCount = 0; - } - - res.json({ - id: guild.id, - name: guild.name, - icon: guild.iconURL(), - channels, - roles, - memberCount, - botCount - }); - } catch (e) { - console.error('Error fetching guild info:', e); - res.status(404).json({ error: 'Guild not found or failed to fetch details.' }); - } -}); - -app.get('/api/members', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { page = 1, limit = 15, search = '', sortBy = 'displayName', sortOrder = 'asc', roleFilter = '' } = req.query; - const guild = await client.guilds.fetch(req.session.guildId); - - // タイムアウトを設定してメンバーを取得(大規模サーバー対応) - try { - await Promise.race([ - guild.members.fetch(), - new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)) - ]); - } catch (fetchError) { - console.warn(`Member fetch timeout or error for guild ${guild.name}, using cache`); - } - - const members = guild.members.cache; - - const levelsRef = collection(db, 'levels'); - const levelsQuery = query(levelsRef, where('guildId', '==', req.session.guildId)); - const levelsSnapshot = await getDocs(levelsQuery); - const levelsData = new Map(levelsSnapshot.docs.map(doc => [doc.data().userId, doc.data()])); - - const warningsRef = collection(db, 'warnings'); - const warningsQuery = query(warningsRef, where('guildId', '==', req.session.guildId)); - const warningsSnapshot = await getDocs(warningsQuery); - const warningsData = new Map(); - warningsSnapshot.forEach(doc => { - const userId = doc.data().userId; - warningsData.set(userId, (warningsData.get(userId) || 0) + 1); - }); - - let memberList = members.map(member => { - const levelInfo = levelsData.get(member.id) || { messageCount: 0 }; - return { - id: member.id, - avatar: member.user.displayAvatarURL(), - username: member.user.username, - displayName: member.displayName, - roles: member.roles.cache.filter(r => r.id !== guild.id).map(r => ({ id: r.id, name: r.name, color: r.hexColor })), - joinedAt: member.joinedTimestamp, - messageCount: levelInfo.messageCount || 0, - warnCount: warningsData.get(member.id) || 0 - }; - }); - - // Filtering - if (search) { - const lowercasedSearch = search.toLowerCase(); - memberList = memberList.filter(m => - m.displayName.toLowerCase().includes(lowercasedSearch) || - m.username.toLowerCase().includes(lowercasedSearch) - ); - } - if (roleFilter) { - memberList = memberList.filter(m => m.roles.some(r => r.id === roleFilter)); - } - - // Sorting - memberList.sort((a, b) => { - let valA = a[sortBy]; - let valB = b[sortBy]; - - if (typeof valA === 'string') valA = valA.toLowerCase(); - if (typeof valB === 'string') valB = valB.toLowerCase(); - - if (valA < valB) return sortOrder === 'asc' ? -1 : 1; - if (valA > valB) return sortOrder === 'asc' ? 1 : -1; - return 0; - }); - - const totalMembers = memberList.length; - const paginatedMembers = memberList.slice((page - 1) * limit, page * limit); - - res.json({ - members: paginatedMembers, - totalMembers, - totalPages: Math.ceil(totalMembers / limit), - currentPage: parseInt(page) - }); - - } catch (error) { - console.error('Error fetching member list:', error); - res.status(500).json({ error: 'Failed to fetch member list.' }); - } -}); - -app.post('/api/members/:memberId/kick', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guild = await client.guilds.fetch(req.session.guildId); - const member = await guild.members.fetch(req.params.memberId); - await member.kick(req.body.reason || '理由なし'); - res.status(200).json({ message: 'Member kicked.' }); - } catch (error) { - console.error('Error kicking member:', error); - res.status(500).json({ error: 'Failed to kick member.' }); - } -}); - -app.post('/api/members/:memberId/ban', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guild = await client.guilds.fetch(req.session.guildId); - const member = await guild.members.fetch(req.params.memberId); - await member.ban({ reason: req.body.reason || '理由なし' }); - res.status(200).json({ message: 'Member banned.' }); - } catch (error) { - console.error('Error banning member:', error); - res.status(500).json({ error: 'Failed to ban member.' }); - } -}); - -app.put('/api/members/:memberId/roles', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guild = await client.guilds.fetch(req.session.guildId); - const member = await guild.members.fetch(req.params.memberId); - await member.roles.set(req.body.roles); - res.status(200).json({ message: 'Roles updated.' }); - } catch (error) { - console.error('Error updating roles:', error); - res.status(500).json({ error: 'Failed to update roles.' }); - } -}); - -app.get('/api/audit-logs', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { eventType, user, page = 1, limit: pageLimit = 15 } = req.query; - const logsRef = collection(db, 'audit_logs'); - let q = query(logsRef, where('guildId', '==', req.session.guildId)); - - if (eventType) { - q = query(q, where('eventType', '==', eventType)); - } - - const countQuery = query(q); - const totalSnapshot = await getCountFromServer(countQuery); - const totalLogs = totalSnapshot.data().count; - - q = query(q, orderBy('timestamp', 'desc')); - - if (page > 1) { - const lastVisibleDocQuery = query(q, limit(pageLimit * (page - 1))); - const lastVisibleSnapshot = await getDocs(lastVisibleDocQuery); - const lastVisible = lastVisibleSnapshot.docs[lastVisibleSnapshot.docs.length - 1]; - if (lastVisible) { - q = query(q, startAfter(lastVisible)); - } - } - - q = query(q, limit(parseInt(pageLimit))); - - const snapshot = await getDocs(q); - let logs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - - if (user) { - logs = logs.filter(log => log.executorTag?.includes(user) || log.targetTag?.includes(user)); - } - - res.json({ - logs, - totalPages: Math.ceil(totalLogs / pageLimit), - currentPage: parseInt(page), - totalLogs - }); - - } catch (error) { - console.error('Error fetching audit logs:', error); - if (error.code === 'failed-precondition') { - const firestoreUrl = `https://console.firebase.google.com/project/${process.env.FIREBASE_PROJECT_ID}/firestore/indexes`; - return res.status(500).json({ - error: 'データベースインデックスが必要です。', - errorCode: 'INDEX_REQUIRED', - fixUrl: firestoreUrl, - message: '監査ログ機能を利用するには、Firestoreの複合インデックスが必要です。エラーログに記載されているURLにアクセスしてインデックスを作成してください。' - }); - } - res.status(500).json({ error: 'Failed to fetch audit logs.' }); - } -}); - -app.get('/api/analytics/activity', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guildId = req.session.guildId; - const guild = await client.guilds.fetch(guildId); - - // --- 1. メッセージ数ランキングと時間帯別アクティビティ --- - const levelsRef = collection(db, 'levels'); - const q = query(levelsRef, where('guildId', '==', guildId)); - const snapshot = await getDocs(q); - - const allUsersData = snapshot.docs.map(doc => doc.data()); - - const topUsers = allUsersData - .sort((a, b) => (b.messageCount || 0) - (a.messageCount || 0)) - .slice(0, 10); - - const topUsersWithDetails = await Promise.all(topUsers.map(async (user) => { - try { - const member = await guild.members.fetch(user.userId); - return { ...user, displayName: member.displayName, username: member.user.username }; - } catch { - return { ...user, displayName: '不明なユーザー', username: 'Unknown' }; - } - })); - - const activityByHour = Array(24).fill(0); - // lastMessageTimestampではなく、messageCountをそのまま使う方がアクティビティの実態に近い - // 正確な時間帯別アクティビティは別途ログが必要なため、これは簡易的な実装 - allUsersData.forEach(user => { - if (user.lastMessageTimestamp) { - const date = user.lastMessageTimestamp.toDate ? user.lastMessageTimestamp.toDate() : new Date(user.lastMessageTimestamp); - const hour = date.getHours(); - activityByHour[hour] += user.messageCount || 0; - } - }); - - // --- 2. ロール分布 --- - // タイムアウトを設定してメンバーを取得 - try { - await Promise.race([ - guild.members.fetch(), - new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)) - ]); - } catch (fetchError) { - console.warn(`Member fetch timeout for analytics in guild ${guild.name}, using cache`); - } - - const roleCounts = {}; - guild.members.cache.forEach(member => { - member.roles.cache.forEach(role => { - if (role.id === guild.id) return; // @everyoneは除外 - roleCounts[role.id] = (roleCounts[role.id] || 0) + 1; - }); - }); - - const roleDistribution = Object.entries(roleCounts) - .map(([roleId, count]) => { - const role = guild.roles.cache.get(roleId); - return { - name: role ? role.name : '不明なロール', - count, - color: role ? role.hexColor : '#808080' - }; - }) - .sort((a, b) => b.count - a.count) - .slice(0, 10); // 上位10ロールに絞る - - res.json({ - topUsers: topUsersWithDetails, - activityByHour, - roleDistribution - }); - - } catch (error) { - console.error('Error fetching analytics data:', error); - res.status(500).json({ error: 'Failed to fetch analytics data.' }); - } -}); - -app.get('/api/settings/welcome-message', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const settingsRef = doc(db, 'guild_settings', req.session.guildId); - const docSnap = await getDoc(settingsRef); - if (docSnap.exists() && docSnap.data().welcomeMessage) { - res.json(docSnap.data().welcomeMessage); - } else { - res.json({ - enabled: true, - type: 'default', - title: '🎉 {server.name} へようこそ!', - description: '**{user.displayName}** さん、サーバーへのご参加ありがとうございます!\n\nまずはルールをご確認ください: {rulesChannel}', - imageUrl: '' - }); - } - } catch (error) { - console.error('Error fetching welcome message settings:', error); - res.status(500).json({ error: 'Failed to fetch welcome message settings.' }); - } -}); - -app.post('/api/settings/welcome-message', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const settingsRef = doc(db, 'guild_settings', req.session.guildId); - await setDoc(settingsRef, { welcomeMessage: req.body }, { merge: true }); - res.status(200).json({ message: 'Welcome message settings updated successfully.' }); - } catch (error) { - console.error('Error updating welcome message settings:', error); - res.status(500).json({ error: 'Failed to update welcome message settings.' }); - } -}); - -app.get('/api/settings/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { collection } = req.params; - if (!['guilds', 'guild_settings'].includes(collection)) { - return res.status(400).json({ error: 'Invalid collection specified.' }); - } - const settingsRef = doc(db, collection, req.session.guildId); - const docSnap = await getDoc(settingsRef); - if (docSnap.exists()) { - res.json(docSnap.data()); - } else { - res.json({}); - } - } catch (error) { - console.error(`Error fetching settings from ${req.params.collection}:`, error); - res.status(500).json({ error: `Failed to fetch settings from ${req.params.collection}.` }); - } -}); - -app.post('/api/settings/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { collection } = req.params; - if (!['guilds', 'guild_settings'].includes(collection)) { - return res.status(400).json({ error: 'Invalid collection specified.' }); - } - const settingsRef = doc(db, collection, req.session.guildId); - await setDoc(settingsRef, req.body, { merge: true }); - res.status(200).json({ message: 'Settings updated successfully.' }); - } catch (error) { - console.error(`Error updating settings for ${req.params.collection}:`, error); - res.status(500).json({ error: `Failed to update settings for ${req.params.collection}.` }); - } -}); - -app.get('/api/roleboards', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const q = query(collection(db, 'roleboards'), where('guildId', '==', req.session.guildId)); - const snapshot = await getDocs(q); - const boards = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - res.json(boards); - } catch (error) { - console.error('Error fetching roleboards:', error); - res.status(500).json({ error: 'Failed to fetch roleboards.' }); - } -}); - -app.post('/api/roleboards', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { title, description, color, password } = req.body; - const guildId = req.session.guildId; - const boardId = `rb_${guildId}_${Date.now()}`; - - // パスワード検証 - if (password && password.length > 128) { - return res.status(400).json({ error: 'パスワードは128文字以内で指定してください。' }); - } - - const boardData = { - guildId, - title, - description: description || 'ボタンをクリックしてロールを取得・削除できます。', - color: parseInt(color.replace('#', ''), 16) || 0x5865F2, - password: password || null, - roles: {}, - genres: {}, - createdBy: req.session.userId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - await setDoc(doc(db, 'roleboards', boardId), boardData); - res.status(201).json({ message: 'Roleboard created successfully.', board: {id: boardId, ...boardData} }); - } catch (error) { - console.error('Error creating roleboard:', error); - res.status(500).json({ error: 'Failed to create roleboard.' }); - } -}); - -app.put('/api/roleboards/:id', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { id } = req.params; - const boardRef = doc(db, 'roleboards', id); - const boardDoc = await getDoc(boardRef); - - if (!boardDoc.exists() || boardDoc.data().guildId !== req.session.guildId) { - return res.status(404).json({ error: 'Roleboard not found.' }); - } - - const updateData = { ...req.body, updatedAt: new Date().toISOString() }; - await updateDoc(boardRef, updateData); - res.status(200).json({ message: 'Roleboard updated successfully.' }); - - } catch (error) { - console.error(`Error updating roleboard ${req.params.id}:`, error); - res.status(500).json({ error: 'Failed to update roleboard.' }); - } -}); - -app.delete('/api/roleboards/:id', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { id } = req.params; - const boardRef = doc(db, 'roleboards', id); - const boardDoc = await getDoc(boardRef); - - if (!boardDoc.exists() || boardDoc.data().guildId !== req.session.guildId) { - return res.status(404).json({ error: 'Roleboard not found.' }); - } - - await deleteDoc(boardRef); - res.status(200).json({ message: 'Roleboard deleted successfully.' }); - - } catch (error) { - console.error(`Error deleting roleboard ${req.params.id}:`, error); - res.status(500).json({ error: 'Failed to delete roleboard.' }); - } -}); - -app.post('/api/admin/login', (req, res) => { - const { password } = req.body; - if (password && password === process.env.ADMIN_PASSWORD) { - req.session.isAdmin = true; - res.status(200).json({ message: 'Admin login successful.' }); - } else { - res.status(401).json({ error: 'Invalid password.' }); - } -}); - -app.post('/api/admin/logout', (req, res) => { - req.session.destroy(err => { - if (err) { - return res.status(500).json({ error: 'Could not log out.' }); - } - res.clearCookie('connect.sid'); - res.status(200).json({ message: 'Logged out successfully.' }); - }); -}); - -app.get('/api/admin/stats', isAdminAuthenticated, async (_req, res) => { - try { - const guilds = await client.guilds.fetch(); - const uptimeSeconds = process.uptime(); - const days = Math.floor(uptimeSeconds / 86400); - const hours = Math.floor((uptimeSeconds % 86400) / 3600); - const minutes = Math.floor((uptimeSeconds % 3600) / 60); - - const recentGuilds = client.guilds.cache.sort((a, b) => b.joinedTimestamp - a.joinedTimestamp).first(5); - - res.json({ - guildCount: client.guilds.cache.size, - userCount: client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0), - uptime: `${days}d ${hours}h ${minutes}m`, - memoryUsage: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2), - bot: { - username: client.user.username, - avatar: client.user.displayAvatarURL(), - }, - recentGuilds: recentGuilds.map(g => ({ - id: g.id, - name: g.name, - memberCount: g.memberCount, - joinedTimestamp: g.joinedTimestamp - })) - }); - } catch (error) { - console.error("Error fetching admin stats:", error); - res.status(500).json({ error: 'Failed to fetch bot statistics.' }); - } -}); - -app.post('/api/admin/announce', isAdminAuthenticated, async (req, res) => { - const { title, description, color, url, footer } = req.body; - if (!title || !description) { - return res.status(400).json({ error: 'Title and description are required.' }); - } - - try { - const embed = new EmbedBuilder() - .setTitle(title) - .setDescription(description) - .setColor(color || '#3498db') - .setTimestamp(); - if (url) embed.setURL(url); - if (footer) embed.setFooter({ text: footer }); - - const settingsRef = collection(db, 'guild_settings'); - const q = query(settingsRef, where('announcementChannelId', '!=', null)); - const snapshot = await getDocs(q); - - let sentCount = 0; - const sendPromises = []; - - snapshot.forEach(doc => { - const settings = doc.data(); - const channelId = settings.announcementChannelId; - - const promise = client.channels.fetch(channelId) - .then(channel => { - if (channel && channel.isTextBased()) { - return channel.send({ embeds: [embed] }).then(() => { - sentCount++; - console.log(chalk.green(`📢 Announcement sent to guild ${doc.id}`)); - }); - } - }) - .catch(err => { - console.error(chalk.red(`Failed to send announcement to channel ${channelId} in guild ${doc.id}:`), err.message); - }); - sendPromises.push(promise); - }); - - await Promise.all(sendPromises); - - res.status(200).json({ message: 'Announcements sent.', sentCount }); - - } catch (error) { - console.error("Error sending announcement:", error); - res.status(500).json({ error: 'Failed to send announcements.' }); - } -}); - -app.get('/api/admin/statuses', isAdminAuthenticated, async (_req, res) => { - const settingsRef = doc(db, 'bot_settings', 'statuses'); - const docSnap = await getDoc(settingsRef); - if (docSnap.exists()) { - res.json(docSnap.data()); - } else { - res.json({ list: [], mode: 'custom' }); - } -}); - -app.post('/api/admin/statuses', isAdminAuthenticated, async (req, res) => { - const { statuses, mode } = req.body; - if (!['ai', 'custom'].includes(mode)) { - return res.status(400).json({ error: 'Invalid mode specified.' }); - } - if (mode === 'custom' && !Array.isArray(statuses)) { - return res.status(400).json({ error: 'Statuses must be an array for custom mode.' }); - } - - try { - const settingsRef = doc(db, 'bot_settings', 'statuses'); - const currentSettings = (await getDoc(settingsRef)).data() || {}; - const newSettings = { - mode: mode, - list: mode === 'custom' ? statuses : currentSettings.list || [] - }; - await setDoc(settingsRef, newSettings); - - statusMode = mode; - if (mode === 'custom') { - dynamicStatuses = statuses; - } - startStatusRotation(); - res.status(200).json({ message: 'Statuses settings updated successfully.' }); - } catch (error) { - console.error("Error updating statuses:", error); - res.status(500).json({ error: 'Failed to update statuses.' }); - } -}); - -// データ管理API -app.get('/api/data-manager/collections', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const guildId = req.session.guildId; - const collections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; - const result = {}; - - for (const collectionName of collections) { - const collectionRef = collection(db, collectionName); - const q = query(collectionRef, where('guildId', '==', guildId)); - const snapshot = await getDocs(q); - result[collectionName] = snapshot.size; - } - - res.json(result); - } catch (error) { - console.error('Error fetching collection counts:', error); - res.status(500).json({ error: 'Failed to fetch data.' }); - } -}); - -app.get('/api/data-manager/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { collection: collectionName } = req.params; - const guildId = req.session.guildId; - const { page = 1, limit: pageLimit = 20 } = req.query; - - const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; - if (!validCollections.includes(collectionName)) { - return res.status(400).json({ error: 'Invalid collection name.' }); - } - - const collectionRef = collection(db, collectionName); - const q = query(collectionRef, where('guildId', '==', guildId)); - const snapshot = await getDocs(q); - - const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); - const totalItems = data.length; - const startIndex = (page - 1) * pageLimit; - const paginatedData = data.slice(startIndex, startIndex + parseInt(pageLimit)); - - res.json({ - data: paginatedData, - totalItems, - totalPages: Math.ceil(totalItems / pageLimit), - currentPage: parseInt(page) - }); - } catch (error) { - console.error(`Error fetching ${req.params.collection}:`, error); - res.status(500).json({ error: 'Failed to fetch data.' }); - } -}); - -app.delete('/api/data-manager/:collection/:id', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { collection: collectionName, id } = req.params; - const guildId = req.session.guildId; - - const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; - if (!validCollections.includes(collectionName)) { - return res.status(400).json({ error: 'Invalid collection name.' }); - } - - const docRef = doc(db, collectionName, id); - const docSnap = await getDoc(docRef); - - if (!docSnap.exists() || docSnap.data().guildId !== guildId) { - return res.status(404).json({ error: 'Document not found or access denied.' }); - } - - await deleteDoc(docRef); - res.status(200).json({ message: 'Document deleted successfully.' }); - } catch (error) { - console.error(`Error deleting document:`, error); - res.status(500).json({ error: 'Failed to delete document.' }); - } -}); - -app.delete('/api/data-manager/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { - try { - const { collection: collectionName } = req.params; - const guildId = req.session.guildId; - - const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; - if (!validCollections.includes(collectionName)) { - return res.status(400).json({ error: 'Invalid collection name.' }); - } - - const collectionRef = collection(db, collectionName); - const q = query(collectionRef, where('guildId', '==', guildId)); - const snapshot = await getDocs(q); - - const deletePromises = snapshot.docs.map(doc => deleteDoc(doc.ref)); - await Promise.all(deletePromises); - - res.status(200).json({ message: `Deleted ${snapshot.size} documents from ${collectionName}.`, count: snapshot.size }); - } catch (error) { - console.error(`Error deleting collection:`, error); - res.status(500).json({ error: 'Failed to delete collection data.' }); - } -}); - -app.get('/', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -app.get('/dashboard', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'dashboard.html')); -}); - -app.get('/data-manager', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'data-manager.html')); -}); - -app.get('/login', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'login.html')); -}); - -app.get('/admin', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'admin.html')); -}); - -app.get('/admin-login.html', (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'admin-login.html')); -}); +app.get('/dashboard', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'dashboard.html'))); +app.get('/login', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'login.html'))); +app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html'))); +// 6. Catch-all / SPA Fallback app.get('*', (req, res) => { - if (!req.path.startsWith('/api/')) { - const filePath = path.join(__dirname, 'public', req.path); - if (fs.existsSync(filePath) && fs.lstatSync(filePath).isFile()) { - return res.sendFile(filePath); - } - return res.redirect('/dashboard'); - } -}); - -const BotStatus = { - INITIALIZING: '🔄 初期化中...', - LOADING_COMMANDS: '📂 コマンド読み込み中...', - LOADING_EVENTS: '🎯 イベント読み込み中...', - CONNECTING: '🌐 Discord に接続中...', - REGISTERING_COMMANDS: '⚙️ コマンド登録中...', - LOADING_STATUS_SETTINGS: '📝 ステータス設定読み込み中...', - READY: '✅ 正常稼働中', - ERROR: '❌ エラー発生' -}; -let currentStatus = BotStatus.INITIALIZING; - -function updateBotStatus(status, details = '') { - currentStatus = status; - console.log(`[${new Date().toLocaleString('ja-JP')}] ${status} ${details}`); -} - -updateBotStatus(BotStatus.LOADING_COMMANDS); -const commandsPath = path.join(__dirname, 'commands'); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); -const commands = []; -for (const file of commandFiles) { - try { - const filePath = path.join(commandsPath, file); - delete require.cache[require.resolve(filePath)]; - const command = require(filePath); - if ('data' in command && 'execute' in command) { - client.commands.set(command.data.name, command); - commands.push(command.data.toJSON()); - } else { - console.log(chalk.yellow(`⚠️ 警告: ${filePath} のコマンドに必要なプロパティがありません。`)); - } - } catch (error) { - console.error(chalk.red(`❌ コマンド読み込みエラー (${file}):`), error); - } -} - -updateBotStatus(BotStatus.LOADING_EVENTS); -const eventsPath = path.join(__dirname, 'events'); -if (fs.existsSync(eventsPath)) { - const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); - for (const file of eventFiles) { - try { - const filePath = path.join(eventsPath, file); - delete require.cache[require.resolve(filePath)]; - const eventHandler = require(filePath); - - if (eventHandler.name && typeof eventHandler.execute === 'function') { - if (eventHandler.once) { - client.once(eventHandler.name, (...args) => eventHandler.execute(...args, client)); - } else { - client.on(eventHandler.name, (...args) => eventHandler.execute(...args, client)); - } - console.log(chalk.blueBright(`[Event] Loaded: ${eventHandler.name} (${file})`)); - } - else if (typeof eventHandler === 'function') { - eventHandler(client); - console.log(chalk.blueBright(`[Event] Loaded modular handler: ${file}`)); - } - } catch (error) { - console.error(chalk.red(`❌ イベント読み込みエラー (${file}):`), error); - } - } -} - -const rest = new REST().setToken(process.env.DISCORD_TOKEN); -async function deployCommands() { - try { - updateBotStatus(BotStatus.REGISTERING_COMMANDS, `${commands.length} 個`); - const data = await rest.put( - Routes.applicationCommands(process.env.CLIENT_ID), - { body: commands } - ); - console.log(chalk.green(`✅ ${data.length} 個のコマンド登録完了。`)); - } catch (error) { - console.error(chalk.red('❌ コマンド登録エラー:'), error); - } -} - -async function generateAIStatus() { - try { - const userCount = client.guilds.cache.reduce((a, g) => a + g.memberCount, 0); - const prompt = `あなたは「OrderlyCore」という名前のDiscordボットです。あなたの現在のユニークで面白いステータスを生成してください。 - -# 指示 -- サーバー数 (${client.guilds.cache.size}個) や、総ユーザー数 (${userCount}人) などの動的な情報を含めることができます。 -- 短く、キャッチーで、少しユーモラスなものが望ましいです。 -- 必ずJSON形式で {"emoji": "絵文字", "state": "ステータスメッセージ"} の形式で出力してください。 -- ステータスメッセージは30文字以内にしてください。 - -# 生成例 -{ "emoji": "☕", "state": "コードをコンパイル中..." } -{ "emoji": "🧠", "state": "${client.guilds.cache.size}個のサーバーを思考中。" } -{ "emoji": "🤖", "state": "AIの夢を見ています。" } -{ "emoji": "📈", "state": "${userCount}人のユーザーを監視中。" }`; - - const result = await client.geminiModel.generateContent(prompt); - const text = result.response.text().replace(/```json|```/g, '').trim(); - return JSON.parse(text); - } catch (error) { - console.error(chalk.red('❌ Geminiによるステータス生成に失敗:'), error); - return { emoji: '⚠️', state: 'AI思考エラー' }; - } -} - -async function loadStatusSettings() { - updateBotStatus(BotStatus.LOADING_STATUS_SETTINGS); - try { - const settingsRef = doc(db, 'bot_settings', 'statuses'); - const docSnap = await getDoc(settingsRef); - - if (docSnap.exists() && docSnap.data().list) { - const settings = docSnap.data(); - statusMode = settings.mode || 'custom'; - console.log(chalk.green(`✅ Firestoreからステータス設定を読み込みました。モード: ${statusMode}`)); - return settings.list; - } else { - console.log(chalk.yellow('⚠️ Firestoreにステータス設定が見つかりません。デフォルトを作成します。')); - const defaultStatuses = [ - { emoji: '✅', state: '正常稼働中' }, - { emoji: '💡', state: '/help でコマンド一覧' }, - { emoji: '🛡️', state: '${serverCount} サーバーを保護中' }, - ]; - await setDoc(settingsRef, { list: defaultStatuses, mode: 'custom' }); - statusMode = 'custom'; - return defaultStatuses; - } - } catch (error) { - console.error(chalk.red('❌ Firestoreからのステータス読み込みに失敗:'), error.message); - return [{ emoji: '❌', state: 'ステータス読込エラー' }]; - } -} - -function startStatusRotation() { - if (statusInterval) { - clearInterval(statusInterval); - } - - let i = 0; - const updateStatus = async () => { - if (!client.isReady()) return; - - let statusToShow; - console.log(chalk.gray(`[Status Rotator] Running update cycle. Mode: ${statusMode}`)); - - if (statusMode === 'ai') { - console.log(chalk.gray(`[Status Rotator] Generating AI status...`)); - statusToShow = await generateAIStatus(); - } else { - if (dynamicStatuses && dynamicStatuses.length > 0) { - const statusTemplate = dynamicStatuses[i]; - const statusState = statusTemplate.state - .replace(/\$\{serverCount\}/g, client.guilds.cache.size) - .replace(/\$\{userCount\}/g, client.guilds.cache.reduce((a, g) => a + g.memberCount, 0)); - statusToShow = { emoji: statusTemplate.emoji, state: statusState }; - i = (i + 1) % dynamicStatuses.length; - } else { - console.log(chalk.yellow(`[Status Rotator] Custom mode is active but no statuses are configured.`)); - statusToShow = { emoji: '🔧', state: 'ステータス設定待ち' }; - } - } - - if (statusToShow) { - try { - await client.user.setPresence({ - activities: [{ - name: 'customstatus', - type: ActivityType.Custom, - state: statusToShow.state, - emoji: statusToShow.emoji - }], - status: 'online' - }); - console.log(chalk.cyan(`🎯 ステータス更新 (${statusMode}): ${statusToShow.emoji} ${statusToShow.state}`)); - } catch (error) { - console.error(chalk.red('[Status Rotator] Failed to set presence:'), error); - } - } else { - console.log(chalk.yellow('[Status Rotator] No status to show in this cycle.')); - } - }; - - updateStatus(); - statusInterval = setInterval(updateStatus, 60000); -} - -const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; - -async function updateRankboards(client) { - if (!client.isReady()) return; - console.log(chalk.cyan('[Rankboard] Starting periodic update...')); - const db = client.db; - const rtdb = client.rtdb; - const settingsCol = collection(db, 'guild_settings'); - const q = query(settingsCol, where('rankBoard', '!=', null)); - - try { - const snapshot = await getDocs(q); - if (snapshot.empty) { - console.log(chalk.cyan('[Rankboard] No active rankboards found.')); - return; - } - - for (const guildSettingsDoc of snapshot.docs) { - const settings = guildSettingsDoc.data(); - const guildId = guildSettingsDoc.id; - const rankBoardConfig = settings.rankBoard; - - if (!rankBoardConfig || !rankBoardConfig.channelId || !rankBoardConfig.messageId) { - continue; - } - - const guild = await client.guilds.fetch(guildId).catch(err => { - console.error(chalk.red(`[Rankboard] Failed to fetch guild ${guildId}`), err); - return null; - }); - if (!guild) continue; - - try { - const levelsRef = collection(db, 'levels'); - const levelQuery = query( - levelsRef, - where('guildId', '==', guildId), - orderBy('level', 'desc'), - orderBy('xp', 'desc'), - limit(10) - ); - const levelSnapshot = await getDocs(levelQuery); - const userStats = []; - levelSnapshot.forEach(doc => { - const data = doc.data(); - userStats.push({ - userId: data.userId, - level: data.level || 0, - xp: data.xp || 0, - }); - }); - - const allSessionsRef = ref(rtdb, `voiceSessions/${guild.id}`); - const allSessionsSnapshot = await get(allSessionsRef); - const onlineUsers = allSessionsSnapshot.exists() ? allSessionsSnapshot.val() : {}; - - const finalStats = userStats.map(stat => { - let currentXp = stat.xp; - if (onlineUsers[stat.userId]) { - const sessionDurationMs = Date.now() - onlineUsers[stat.userId].joinedAt; - const minutesStayed = Math.floor(sessionDurationMs / 60000); - const vcXpGained = minutesStayed * 5; - currentXp += vcXpGained; - } - return { ...stat, finalXp: currentXp }; - }); - - finalStats.sort((a, b) => { - if (b.level !== a.level) { - return b.level - a.level; - } - return b.finalXp - a.finalXp; - }); - - const rankEmbed = new EmbedBuilder() - .setColor(0x00FFFF) - .setTitle(`🏆 ${guild.name} リアルタイムランキング`) - .setThumbnail(guild.iconURL({ dynamic: true })) - .setTimestamp() - .setFooter({ text: '🟢: VC参加中 | 5分ごとに更新' }); - - if (finalStats.length === 0) { - rankEmbed.setDescription('まだデータがありません。\nメンバーがチャットやVCで活動を始めると、ここにランキングが表示されます。'); - } else { - const rankPromises = finalStats.map(async (stat, index) => { - const member = await guild.members.fetch(stat.userId).catch(() => null); - const medal = ['🥇', '🥈', '🥉'][index] || `**#${index + 1}**`; - const requiredXp = calculateRequiredXp(stat.level); - const isOnline = onlineUsers[stat.userId] ? '🟢' : ''; - - return `${medal} ${isOnline} **${member ? member.displayName : '不明なユーザー'}**\n> LV: \`${stat.level}\` | XP: \`${stat.finalXp.toLocaleString()} / ${requiredXp.toLocaleString()}\``; - }); - const rankStrings = await Promise.all(rankPromises); - rankEmbed.setDescription(rankStrings.join('\n\n')); - } - - const channel = await client.channels.fetch(rankBoardConfig.channelId).catch(() => null); - if (channel) { - const message = await channel.messages.fetch(rankBoardConfig.messageId).catch(() => null); - if (message) { - await message.edit({ embeds: [rankEmbed] }); - console.log(chalk.green(`[Rankboard] Updated for guild: ${guild.name}`)); - } - } - } catch (error) { - console.error(chalk.red(`[Rankboard] Error updating board for guild ${guildId}:`), error); - } - } - } catch (error) { - console.error(chalk.red('[Rankboard] Failed to query for guild settings:'), error); - } + if (!req.path.startsWith('/api/')) { + const filePath = path.join(__dirname, 'public', req.path); + // Serve file if it exists, otherwise redirect to dashboard + if (fs.existsSync(filePath) && fs.lstatSync(filePath).isFile()) { + return res.sendFile(filePath); + } + return res.redirect('/dashboard'); + } + res.status(404).json({ error: 'Not Found' }); +}); + +// 7. Discord Bot Initialization +const commands = loadCommands(); +loadEvents(); + +// 8. Bot Login & Command Deployment +client.login(process.env.DISCORD_TOKEN).then(async () => { + console.log(chalk.green('✅ Discord bot logged in.')); + const { deployCommands } = require('./src/config/discord'); + await deployCommands(commands); +}).catch(err => { + console.error(chalk.red('❌ Discord bot login failed:'), err); +}); + +// 9. Server Initialization +if (require.main === module) { + app.listen(PORT, () => { + console.log(chalk.bold.cyan(` +-------------------------------------------------- +🚀 OrderlyCore Bot is starting up! +🌐 Web Server: http://localhost:${PORT} +🔧 Environment: ${NODE_ENV} +-------------------------------------------------- + `)); + }); } -client.once('ready', async () => { - console.log(chalk.bold.greenBright(`🚀 ${client.user.tag} が起動しました!`)); - await deployCommands(); - - app.listen(PORT, () => console.log(chalk.green(`✅ Webサーバーがポート ${PORT} で起動しました。`))); - - keepAlive(); - - setTimeout(() => updateRankboards(client), 10000); - setInterval(() => updateRankboards(client), 5 * 60 * 1000); - - dynamicStatuses = await loadStatusSettings(); - startStatusRotation(); - - updateBotStatus(BotStatus.READY); +// 10. Global Error Handlers +process.on('unhandledRejection', (reason, promise) => { + console.error(chalk.red('Unhandled Rejection at:', promise, 'reason:', reason)); }); -client.on('error', console.error); -process.on('unhandledRejection', error => { - console.error('Unhandled promise rejection:', error); +process.on('uncaughtException', (err) => { + console.error(chalk.red('Uncaught Exception thrown:'), err); }); -updateBotStatus(BotStatus.CONNECTING); -client.login(process.env.DISCORD_TOKEN); \ No newline at end of file +module.exports = app; diff --git a/src/config/discord.js b/src/config/discord.js new file mode 100644 index 0000000..048e383 --- /dev/null +++ b/src/config/discord.js @@ -0,0 +1,100 @@ +const { Client, GatewayIntentBits, Partials, Collection, REST, Routes } = require('discord.js'); +const { GoogleGenerativeAI } = require('@google/generative-ai'); +const { db, rtdb } = require('./firebase'); +const fs = require('node:fs'); +const path = require('node:path'); +const chalk = require('chalk'); + +const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY); +const geminiModel = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessageReactions + ], + partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.GuildMember] +}); + +client.db = db; +client.rtdb = rtdb; +client.commands = new Collection(); +client.geminiModel = geminiModel; + +function loadCommands() { + console.log(chalk.blue('📂 Loading commands...')); + const commandsPath = path.join(__dirname, '../../commands'); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')); + const commands = []; + + for (const file of commandFiles) { + try { + const filePath = path.join(commandsPath, file); + delete require.cache[require.resolve(filePath)]; + const command = require(filePath); + if ('data' in command && 'execute' in command) { + client.commands.set(command.data.name, command); + commands.push(command.data.toJSON()); + } else { + console.warn(chalk.yellow(`⚠️ Warning: Command at ${filePath} is missing required properties.`)); + } + } catch (error) { + console.error(chalk.red(`❌ Error loading command ${file}:`), error); + } + } + return commands; +} + +function loadEvents() { + console.log(chalk.blue('🎯 Loading events...')); + const eventsPath = path.join(__dirname, '../../events'); + if (fs.existsSync(eventsPath)) { + const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js')); + for (const file of eventFiles) { + try { + const filePath = path.join(eventsPath, file); + delete require.cache[require.resolve(filePath)]; + const eventHandler = require(filePath); + + if (eventHandler.name && typeof eventHandler.execute === 'function') { + if (eventHandler.once) { + client.once(eventHandler.name, (...args) => eventHandler.execute(...args, client)); + } else { + client.on(eventHandler.name, (...args) => eventHandler.execute(...args, client)); + } + console.log(chalk.blueBright(`[Event] Loaded: ${eventHandler.name} (${file})`)); + } else if (typeof eventHandler === 'function') { + eventHandler(client); + console.log(chalk.blueBright(`[Event] Loaded modular handler: ${file}`)); + } + } catch (error) { + console.error(chalk.red(`❌ Error loading event ${file}:`), error); + } + } + } +} + +async function deployCommands(commands) { + const rest = new REST().setToken(process.env.DISCORD_TOKEN); + try { + console.log(chalk.blue(`⚙️ Registering ${commands.length} commands...`)); + const data = await rest.put( + Routes.applicationCommands(process.env.CLIENT_ID), + { body: commands } + ); + console.log(chalk.green(`✅ Successfully registered ${data.length} commands.`)); + } catch (error) { + console.error(chalk.red('❌ Error registering commands:'), error); + } +} + +module.exports = { + client, + loadCommands, + loadEvents, + deployCommands +}; diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..724a1df --- /dev/null +++ b/src/config/env.js @@ -0,0 +1,36 @@ +const chalk = require('chalk'); + +const requiredEnvVars = [ + 'DISCORD_TOKEN', + 'CLIENT_ID', + 'FIREBASE_API_KEY', + 'FIREBASE_AUTH_DOMAIN', + 'FIREBASE_DATABASE_URL', + 'FIREBASE_PROJECT_ID', + 'FIREBASE_STORAGE_BUCKET', + 'FIREBASE_MESSAGING_SENDER_ID', + 'FIREBASE_APP_ID', + 'GOOGLE_API_KEY', + 'SESSION_SECRET', + 'ADMIN_PASSWORD' +]; + +function validateEnv() { + const missing = requiredEnvVars.filter(envVar => !process.env[envVar]); + + if (missing.length > 0) { + console.error(chalk.red('❌ Missing required environment variables:')); + missing.forEach(envVar => console.error(chalk.red(` - ${envVar}`))); + process.exit(1); + } + + console.log(chalk.green('✅ Environment variables validated successfully.')); +} + +module.exports = { + validateEnv, + PORT: process.env.PORT || 8000, + FRONTEND_URL: process.env.FRONTEND_URL, + NODE_ENV: process.env.NODE_ENV || 'development', + APP_URL: process.env.APP_URL +}; diff --git a/src/config/firebase.js b/src/config/firebase.js new file mode 100644 index 0000000..8f4db22 --- /dev/null +++ b/src/config/firebase.js @@ -0,0 +1,24 @@ +const { initializeApp } = require('firebase/app'); +const { getFirestore } = require('firebase/firestore'); +const { getDatabase } = require('firebase/database'); + +const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + databaseURL: process.env.FIREBASE_DATABASE_URL, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + measurementId: process.env.FIREBASE_MEASUREMENT_ID +}; + +const firebaseApp = initializeApp(firebaseConfig); +const db = getFirestore(firebaseApp); +const rtdb = getDatabase(firebaseApp); + +module.exports = { + db, + rtdb, + firebaseApp +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..c97a089 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,39 @@ +const { PermissionsBitField } = require('discord.js'); +const { client } = require('../config/discord'); + +const isAuthenticated = (req, res, next) => { + if (req.session.userId && req.session.guildId) { + return next(); + } + res.status(401).json({ error: 'Unauthorized. Please login again.' }); +}; + +const isGuildAdmin = async (req, res, next) => { + try { + if (!req.session.guildId || !req.session.userId) { + return res.status(401).json({ error: 'Unauthorized.' }); + } + const guild = await client.guilds.fetch(req.session.guildId); + const member = await guild.members.fetch(req.session.userId); + if (member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return next(); + } + res.status(403).json({ error: 'Forbidden: You are not an administrator of this server.' }); + } catch (error) { + console.error('Error checking guild admin status:', error); + res.status(500).json({ error: 'Internal Server Error while verifying permissions.' }); + } +}; + +const isAdminAuthenticated = (req, res, next) => { + if (req.session.isAdmin) { + return next(); + } + res.status(401).json({ error: 'Administrator access required.' }); +}; + +module.exports = { + isAuthenticated, + isGuildAdmin, + isAdminAuthenticated +}; diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..b0580a3 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,133 @@ +const express = require('express'); +const router = express.Router(); +const { isAdminAuthenticated } = require('../middleware/auth'); +const { client } = require('../config/discord'); +const { db } = require('../config/firebase'); +const { doc, getDoc, setDoc, collection, query, where, getDocs } = require('firebase/firestore'); +const { EmbedBuilder } = require('discord.js'); +const chalk = require('chalk'); + +router.get('/stats', isAdminAuthenticated, async (_req, res) => { + try { + const uptimeSeconds = process.uptime(); + const days = Math.floor(uptimeSeconds / 86400); + const hours = Math.floor((uptimeSeconds % 86400) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + + const recentGuilds = client.guilds.cache.sort((a, b) => b.joinedTimestamp - a.joinedTimestamp).first(5); + + res.json({ + guildCount: client.guilds.cache.size, + userCount: client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0), + uptime: `${days}d ${hours}h ${minutes}m`, + memoryUsage: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2), + bot: { + username: client.user.username, + avatar: client.user.displayAvatarURL(), + }, + recentGuilds: recentGuilds.map(g => ({ + id: g.id, + name: g.name, + memberCount: g.memberCount, + joinedTimestamp: g.joinedTimestamp + })) + }); + } catch (error) { + console.error("Error fetching admin stats:", error); + res.status(500).json({ error: 'Failed to fetch bot statistics.' }); + } +}); + +router.post('/announce', isAdminAuthenticated, async (req, res) => { + const { title, description, color, url, footer } = req.body; + if (!title || !description) { + return res.status(400).json({ error: 'Title and description are required.' }); + } + + try { + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(description) + .setColor(color || '#3498db') + .setTimestamp(); + if (url) embed.setURL(url); + if (footer) embed.setFooter({ text: footer }); + + const settingsRef = collection(db, 'guild_settings'); + const q = query(settingsRef, where('announcementChannelId', '!=', null)); + const snapshot = await getDocs(q); + + let sentCount = 0; + const sendPromises = []; + + snapshot.forEach(doc => { + const settings = doc.data(); + const channelId = settings.announcementChannelId; + + const promise = client.channels.fetch(channelId) + .then(channel => { + if (channel && channel.isTextBased()) { + return channel.send({ embeds: [embed] }).then(() => { + sentCount++; + console.log(chalk.green(`📢 Announcement sent to guild ${doc.id}`)); + }); + } + }) + .catch(err => { + console.error(chalk.red(`Failed to send announcement to channel ${channelId} in guild ${doc.id}:`), err.message); + }); + sendPromises.push(promise); + }); + + await Promise.all(sendPromises); + res.status(200).json({ message: 'Announcements sent.', sentCount }); + } catch (error) { + console.error("Error sending announcement:", error); + res.status(500).json({ error: 'Failed to send announcements.' }); + } +}); + +router.get('/statuses', isAdminAuthenticated, async (_req, res) => { + try { + const settingsRef = doc(db, 'bot_settings', 'statuses'); + const docSnap = await getDoc(settingsRef); + if (docSnap.exists()) { + res.json(docSnap.data()); + } else { + res.json({ list: [], mode: 'custom' }); + } + } catch (error) { + console.error("Error fetching statuses:", error); + res.status(500).json({ error: 'Failed to fetch statuses.' }); + } +}); + +router.post('/statuses', isAdminAuthenticated, async (req, res) => { + const { statuses, mode } = req.body; + if (!['ai', 'custom'].includes(mode)) { + return res.status(400).json({ error: 'Invalid mode specified.' }); + } + if (mode === 'custom' && !Array.isArray(statuses)) { + return res.status(400).json({ error: 'Statuses must be an array for custom mode.' }); + } + + try { + const settingsRef = doc(db, 'bot_settings', 'statuses'); + const currentSettings = (await getDoc(settingsRef)).data() || {}; + const newSettings = { + mode: mode, + list: mode === 'custom' ? statuses : currentSettings.list || [] + }; + await setDoc(settingsRef, newSettings); + + // Note: Actual status update is handled by statusService, but we need to notify it or update global state. + // For now, we'll assume the status rotation will pick up the changes from DB. + + res.status(200).json({ message: 'Statuses settings updated successfully.' }); + } catch (error) { + console.error("Error updating statuses:", error); + res.status(500).json({ error: 'Failed to update statuses.' }); + } +}); + +module.exports = router; diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..2e0cc0e --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,507 @@ +const express = require('express'); +const router = express.Router(); +const { isAuthenticated, isGuildAdmin } = require('../middleware/auth'); +const { client } = require('../config/discord'); +const { db } = require('../config/firebase'); +const { collection, query, where, getDocs, getDoc, doc, setDoc, updateDoc, deleteDoc, orderBy, limit, startAfter, getCountFromServer } = require('firebase/firestore'); + +router.get('/guild-info', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guild = await client.guilds.fetch(req.session.guildId); + const channels = guild.channels.cache + .filter(c => c.type === 0 || c.type === 2) + .map(c => ({ id: c.id, name: c.name, type: c.type })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const roles = guild.roles.cache + .filter(r => r.id !== guild.id) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })) + .sort((a,b) => a.name.localeCompare(b.name)); + + let memberCount = guild.memberCount || 0; + let botCount = guild.members.cache.filter(member => member.user.bot).size; + + res.json({ + id: guild.id, + name: guild.name, + icon: guild.iconURL(), + channels, + roles, + memberCount, + botCount + }); + } catch (e) { + console.error('Error fetching guild info:', e); + res.status(404).json({ error: 'Guild not found or failed to fetch details.' }); + } +}); + +router.get('/members', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { page = 1, limit = 15, search = '', sortBy = 'displayName', sortOrder = 'asc', roleFilter = '' } = req.query; + const guild = await client.guilds.fetch(req.session.guildId); + + try { + await Promise.race([ + guild.members.fetch(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)) + ]); + } catch (fetchError) { + console.warn(`Member fetch timeout or error for guild ${guild.name}, using cache`); + } + + const members = guild.members.cache; + + const levelsRef = collection(db, 'levels'); + const levelsQuery = query(levelsRef, where('guildId', '==', req.session.guildId)); + const levelsSnapshot = await getDocs(levelsQuery); + const levelsData = new Map(levelsSnapshot.docs.map(doc => [doc.data().userId, doc.data()])); + + const warningsRef = collection(db, 'warnings'); + const warningsQuery = query(warningsRef, where('guildId', '==', req.session.guildId)); + const warningsSnapshot = await getDocs(warningsQuery); + const warningsData = new Map(); + warningsSnapshot.forEach(doc => { + const userId = doc.data().userId; + warningsData.set(userId, (warningsData.get(userId) || 0) + 1); + }); + + let memberList = members.map(member => { + const levelInfo = levelsData.get(member.id) || { messageCount: 0 }; + return { + id: member.id, + avatar: member.user.displayAvatarURL(), + username: member.user.username, + displayName: member.displayName, + roles: member.roles.cache.filter(r => r.id !== guild.id).map(r => ({ id: r.id, name: r.name, color: r.hexColor })), + joinedAt: member.joinedTimestamp, + messageCount: levelInfo.messageCount || 0, + warnCount: warningsData.get(member.id) || 0 + }; + }); + + if (search) { + const lowercasedSearch = search.toLowerCase(); + memberList = memberList.filter(m => + m.displayName.toLowerCase().includes(lowercasedSearch) || + m.username.toLowerCase().includes(lowercasedSearch) + ); + } + if (roleFilter) { + memberList = memberList.filter(m => m.roles.some(r => r.id === roleFilter)); + } + + memberList.sort((a, b) => { + let valA = a[sortBy]; + let valB = b[sortBy]; + if (typeof valA === 'string') valA = valA.toLowerCase(); + if (typeof valB === 'string') valB = valB.toLowerCase(); + if (valA < valB) return sortOrder === 'asc' ? -1 : 1; + if (valA > valB) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + + const totalMembers = memberList.length; + const paginatedMembers = memberList.slice((page - 1) * limit, page * limit); + + res.json({ + members: paginatedMembers, + totalMembers, + totalPages: Math.ceil(totalMembers / limit), + currentPage: parseInt(page) + }); + } catch (error) { + console.error('Error fetching member list:', error); + res.status(500).json({ error: 'Failed to fetch member list.' }); + } +}); + +router.post('/members/:memberId/kick', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guild = await client.guilds.fetch(req.session.guildId); + const member = await guild.members.fetch(req.params.memberId); + await member.kick(req.body.reason || '理由なし'); + res.status(200).json({ message: 'Member kicked.' }); + } catch (error) { + console.error('Error kicking member:', error); + res.status(500).json({ error: 'Failed to kick member.' }); + } +}); + +router.post('/members/:memberId/ban', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guild = await client.guilds.fetch(req.session.guildId); + const member = await guild.members.fetch(req.params.memberId); + await member.ban({ reason: req.body.reason || '理由なし' }); + res.status(200).json({ message: 'Member banned.' }); + } catch (error) { + console.error('Error banning member:', error); + res.status(500).json({ error: 'Failed to ban member.' }); + } +}); + +router.put('/members/:memberId/roles', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guild = await client.guilds.fetch(req.session.guildId); + const member = await guild.members.fetch(req.params.memberId); + await member.roles.set(req.body.roles); + res.status(200).json({ message: 'Roles updated.' }); + } catch (error) { + console.error('Error updating roles:', error); + res.status(500).json({ error: 'Failed to update roles.' }); + } +}); + +router.get('/audit-logs', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { eventType, user, page = 1, limit: pageLimit = 15 } = req.query; + const logsRef = collection(db, 'audit_logs'); + let q = query(logsRef, where('guildId', '==', req.session.guildId)); + + if (eventType) { + q = query(q, where('eventType', '==', eventType)); + } + + const countQuery = query(q); + const totalSnapshot = await getCountFromServer(countQuery); + const totalLogs = totalSnapshot.data().count; + + q = query(q, orderBy('timestamp', 'desc')); + + if (page > 1) { + const lastVisibleDocQuery = query(q, limit(pageLimit * (page - 1))); + const lastVisibleSnapshot = await getDocs(lastVisibleDocQuery); + const lastVisible = lastVisibleSnapshot.docs[lastVisibleSnapshot.docs.length - 1]; + if (lastVisible) { + q = query(q, startAfter(lastVisible)); + } + } + + q = query(q, limit(parseInt(pageLimit))); + + const snapshot = await getDocs(q); + let logs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); + + if (user) { + logs = logs.filter(log => log.executorTag?.includes(user) || log.targetTag?.includes(user)); + } + + res.json({ + logs, + totalPages: Math.ceil(totalLogs / pageLimit), + currentPage: parseInt(page), + totalLogs + }); + } catch (error) { + console.error('Error fetching audit logs:', error); + if (error.code === 'failed-precondition') { + return res.status(500).json({ + error: 'データベースインデックスが必要です。', + errorCode: 'INDEX_REQUIRED', + message: 'Firestoreの複合インデックスが必要です。' + }); + } + res.status(500).json({ error: 'Failed to fetch audit logs.' }); + } +}); + +router.get('/analytics/activity', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guildId = req.session.guildId; + const guild = await client.guilds.fetch(guildId); + + const levelsRef = collection(db, 'levels'); + const q = query(levelsRef, where('guildId', '==', guildId)); + const snapshot = await getDocs(q); + + const allUsersData = snapshot.docs.map(doc => doc.data()); + + const topUsers = allUsersData + .sort((a, b) => (b.messageCount || 0) - (a.messageCount || 0)) + .slice(0, 10); + + const topUsersWithDetails = await Promise.all(topUsers.map(async (user) => { + try { + const member = await guild.members.fetch(user.userId); + return { ...user, displayName: member.displayName, username: member.user.username }; + } catch { + return { ...user, displayName: '不明なユーザー', username: 'Unknown' }; + } + })); + + const activityByHour = Array(24).fill(0); + allUsersData.forEach(user => { + if (user.lastMessageTimestamp) { + const date = user.lastMessageTimestamp.toDate ? user.lastMessageTimestamp.toDate() : new Date(user.lastMessageTimestamp); + const hour = date.getHours(); + activityByHour[hour] += user.messageCount || 0; + } + }); + + try { + await Promise.race([ + guild.members.fetch(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)) + ]); + } catch (fetchError) { + console.warn(`Member fetch timeout for analytics in guild ${guild.name}, using cache`); + } + + const roleCounts = {}; + guild.members.cache.forEach(member => { + member.roles.cache.forEach(role => { + if (role.id === guild.id) return; + roleCounts[role.id] = (roleCounts[role.id] || 0) + 1; + }); + }); + + const roleDistribution = Object.entries(roleCounts) + .map(([roleId, count]) => { + const role = guild.roles.cache.get(roleId); + return { + name: role ? role.name : '不明なロール', + count, + color: role ? role.hexColor : '#808080' + }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + res.json({ + topUsers: topUsersWithDetails, + activityByHour, + roleDistribution + }); + } catch (error) { + console.error('Error fetching analytics data:', error); + res.status(500).json({ error: 'Failed to fetch analytics data.' }); + } +}); + +router.get('/settings/welcome-message', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const settingsRef = doc(db, 'guild_settings', req.session.guildId); + const docSnap = await getDoc(settingsRef); + if (docSnap.exists() && docSnap.data().welcomeMessage) { + res.json(docSnap.data().welcomeMessage); + } else { + res.json({ + enabled: true, + type: 'default', + title: '🎉 {server.name} へようこそ!', + description: '**{user.displayName}** さん、サーバーへのご参加ありがとうございます!\n\nまずはルールをご確認ください: {rulesChannel}', + imageUrl: '' + }); + } + } catch (error) { + console.error('Error fetching welcome message settings:', error); + res.status(500).json({ error: 'Failed to fetch welcome message settings.' }); + } +}); + +router.post('/settings/welcome-message', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const settingsRef = doc(db, 'guild_settings', req.session.guildId); + await setDoc(settingsRef, { welcomeMessage: req.body }, { merge: true }); + res.status(200).json({ message: 'Welcome message settings updated successfully.' }); + } catch (error) { + console.error('Error updating welcome message settings:', error); + res.status(500).json({ error: 'Failed to update welcome message settings.' }); + } +}); + +router.get('/settings/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { collection: collectionName } = req.params; + if (!['guilds', 'guild_settings'].includes(collectionName)) { + return res.status(400).json({ error: 'Invalid collection specified.' }); + } + const settingsRef = doc(db, collectionName, req.session.guildId); + const docSnap = await getDoc(settingsRef); + if (docSnap.exists()) { + res.json(docSnap.data()); + } else { + res.json({}); + } + } catch (error) { + console.error(`Error fetching settings from ${req.params.collection}:`, error); + res.status(500).json({ error: `Failed to fetch settings from ${req.params.collection}.` }); + } +}); + +router.post('/settings/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { collection: collectionName } = req.params; + if (!['guilds', 'guild_settings'].includes(collectionName)) { + return res.status(400).json({ error: 'Invalid collection specified.' }); + } + const settingsRef = doc(db, collectionName, req.session.guildId); + await setDoc(settingsRef, req.body, { merge: true }); + res.status(200).json({ message: 'Settings updated successfully.' }); + } catch (error) { + console.error(`Error updating settings for ${req.params.collection}:`, error); + res.status(500).json({ error: `Failed to update settings for ${req.params.collection}.` }); + } +}); + +router.get('/roleboards', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const q = query(collection(db, 'roleboards'), where('guildId', '==', req.session.guildId)); + const snapshot = await getDocs(q); + const boards = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); + res.json(boards); + } catch (error) { + console.error('Error fetching roleboards:', error); + res.status(500).json({ error: 'Failed to fetch roleboards.' }); + } +}); + +router.post('/roleboards', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { title, description, color, password } = req.body; + const guildId = req.session.guildId; + const boardId = `rb_${guildId}_${Date.now()}`; + if (password && password.length > 128) { + return res.status(400).json({ error: 'パスワードは128文字以内で指定してください。' }); + } + const boardData = { + guildId, + title, + description: description || 'ボタンをクリックしてロールを取得・削除できます。', + color: parseInt(color.replace('#', ''), 16) || 0x5865F2, + password: password || null, + roles: {}, + genres: {}, + createdBy: req.session.userId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + await setDoc(doc(db, 'roleboards', boardId), boardData); + res.status(201).json({ message: 'Roleboard created successfully.', board: {id: boardId, ...boardData} }); + } catch (error) { + console.error('Error creating roleboard:', error); + res.status(500).json({ error: 'Failed to create roleboard.' }); + } +}); + +router.put('/roleboards/:id', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { id } = req.params; + const boardRef = doc(db, 'roleboards', id); + const boardDoc = await getDoc(boardRef); + if (!boardDoc.exists() || boardDoc.data().guildId !== req.session.guildId) { + return res.status(404).json({ error: 'Roleboard not found.' }); + } + const updateData = { ...req.body, updatedAt: new Date().toISOString() }; + await updateDoc(boardRef, updateData); + res.status(200).json({ message: 'Roleboard updated successfully.' }); + } catch (error) { + console.error(`Error updating roleboard ${req.params.id}:`, error); + res.status(500).json({ error: 'Failed to update roleboard.' }); + } +}); + +router.delete('/roleboards/:id', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { id } = req.params; + const boardRef = doc(db, 'roleboards', id); + const boardDoc = await getDoc(boardRef); + if (!boardDoc.exists() || boardDoc.data().guildId !== req.session.guildId) { + return res.status(404).json({ error: 'Roleboard not found.' }); + } + await deleteDoc(boardRef); + res.status(200).json({ message: 'Roleboard deleted successfully.' }); + } catch (error) { + console.error(`Error deleting roleboard ${req.params.id}:`, error); + res.status(500).json({ error: 'Failed to delete roleboard.' }); + } +}); + +router.get('/data-manager/collections', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const guildId = req.session.guildId; + const collections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; + const result = {}; + for (const collectionName of collections) { + const collectionRef = collection(db, collectionName); + const q = query(collectionRef, where('guildId', '==', guildId)); + const snapshot = await getDocs(q); + result[collectionName] = snapshot.size; + } + res.json(result); + } catch (error) { + console.error('Error fetching collection counts:', error); + res.status(500).json({ error: 'Failed to fetch data.' }); + } +}); + +router.get('/data-manager/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { collection: collectionName } = req.params; + const guildId = req.session.guildId; + const { page = 1, limit: pageLimit = 20 } = req.query; + const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; + if (!validCollections.includes(collectionName)) { + return res.status(400).json({ error: 'Invalid collection name.' }); + } + const collectionRef = collection(db, collectionName); + const q = query(collectionRef, where('guildId', '==', guildId)); + const snapshot = await getDocs(q); + const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); + const totalItems = data.length; + const startIndex = (page - 1) * pageLimit; + const paginatedData = data.slice(startIndex, startIndex + parseInt(pageLimit)); + res.json({ + data: paginatedData, + totalItems, + totalPages: Math.ceil(totalItems / pageLimit), + currentPage: parseInt(page) + }); + } catch (error) { + console.error(`Error fetching ${req.params.collection}:`, error); + res.status(500).json({ error: 'Failed to fetch data.' }); + } +}); + +router.delete('/data-manager/:collection/:id', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { collection: collectionName, id } = req.params; + const guildId = req.session.guildId; + const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; + if (!validCollections.includes(collectionName)) { + return res.status(400).json({ error: 'Invalid collection name.' }); + } + const docRef = doc(db, collectionName, id); + const docSnap = await getDoc(docRef); + if (!docSnap.exists() || docSnap.data().guildId !== guildId) { + return res.status(404).json({ error: 'Document not found or access denied.' }); + } + await deleteDoc(docRef); + res.status(200).json({ message: 'Document deleted successfully.' }); + } catch (error) { + console.error(`Error deleting document:`, error); + res.status(500).json({ error: 'Failed to delete document.' }); + } +}); + +router.delete('/data-manager/:collection', isAuthenticated, isGuildAdmin, async (req, res) => { + try { + const { collection: collectionName } = req.params; + const guildId = req.session.guildId; + const validCollections = ['levels', 'warnings', 'audit_logs', 'quotes', 'roleboards']; + if (!validCollections.includes(collectionName)) { + return res.status(400).json({ error: 'Invalid collection name.' }); + } + const collectionRef = collection(db, collectionName); + const q = query(collectionRef, where('guildId', '==', guildId)); + const snapshot = await getDocs(q); + const deletePromises = snapshot.docs.map(doc => deleteDoc(doc.ref)); + await Promise.all(deletePromises); + res.status(200).json({ message: `Deleted ${snapshot.size} documents from ${collectionName}.`, count: snapshot.size }); + } catch (error) { + console.error(`Error deleting collection:`, error); + res.status(500).json({ error: 'Failed to delete collection data.' }); + } +}); + +module.exports = router; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..2696e74 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,63 @@ +const express = require('express'); +const router = express.Router(); +const { ref, get, remove } = require('firebase/database'); +const { rtdb } = require('../config/firebase'); + +router.post('/verify', async (req, res) => { + const { token } = req.body; + if (!token) return res.status(400).json({ error: 'Token is required.' }); + + const tokenRef = ref(rtdb, `authTokens/${token}`); + try { + const snapshot = await get(tokenRef); + if (snapshot.exists()) { + const data = snapshot.val(); + if (data.expiresAt > Date.now()) { + req.session.userId = data.userId; + req.session.guildId = data.guildId; + await remove(tokenRef); + return res.status(200).json({ message: 'Login successful!', guildId: data.guildId }); + } else { + await remove(tokenRef); + return res.status(401).json({ error: 'Token has expired.' }); + } + } else { + return res.status(401).json({ error: 'Invalid token.' }); + } + } catch (error) { + console.error("Token verification error:", error); + return res.status(500).json({ error: 'Database error during token verification.' }); + } +}); + +router.post('/logout', (req, res) => { + req.session.destroy(err => { + if (err) { + return res.status(500).json({ error: 'Could not log out.' }); + } + res.clearCookie('connect.sid'); + res.status(200).json({ message: 'Logged out successfully.' }); + }); +}); + +router.post('/admin/login', (req, res) => { + const { password } = req.body; + if (password && password === process.env.ADMIN_PASSWORD) { + req.session.isAdmin = true; + res.status(200).json({ message: 'Admin login successful.' }); + } else { + res.status(401).json({ error: 'Invalid password.' }); + } +}); + +router.post('/admin/logout', (req, res) => { + req.session.destroy(err => { + if (err) { + return res.status(500).json({ error: 'Could not log out.' }); + } + res.clearCookie('connect.sid'); + res.status(200).json({ message: 'Logged out successfully.' }); + }); +}); + +module.exports = router; diff --git a/src/services/levelingService.js b/src/services/levelingService.js new file mode 100644 index 0000000..681fbb7 --- /dev/null +++ b/src/services/levelingService.js @@ -0,0 +1,114 @@ +const { EmbedBuilder, PermissionsBitField } = require('discord.js'); +const { doc, getDoc, setDoc, collection, query, where, orderBy, getDocs } = require('firebase/firestore'); +const chalk = require('chalk'); + +const calculateRequiredXp = (level) => 5 * (level ** 2) + 50 * level + 100; + +async function generateLevelUpComment(client, user, newLevel, serverName) { + try { + const prompt = `あなたはDiscordサーバーの優秀なアシスタントです。以下の指示に従って、ユーザーのレベルアップを祝福するメッセージを**一行で**生成してください。 + +### 指示 +* **役割**: ユーザーの功績を称え、今後の活躍を期待させるような、ユニークでクリエイティブなメッセージを作成します。 +* **トーン**: 非常にポジティブで、少し壮大な雰囲気にしてください。 +* **必須要素**: + * ユーザー名: ${user.displayName} + * 新しいレベル: ${newLevel} + * サーバー名: ${serverName} +* **厳格な制約**: + * 生成する文章は**必ず一行**にしてください。 + * **80文字以内**に収めてください。 + * 毎回必ず違うパターンの文章を生成してください。 + * **回答には祝福メッセージのみを含め、それ以外の前置き、解説、リスト、引用符(「」)は絶対に含めないでください。**`; + + const result = await client.geminiModel.generateContent(prompt); + const text = result.response.text().trim().replace(/[\n*「」]/g, '').split('。')[0]; + return text; + } catch (error) { + console.error(chalk.red('❌ Gemini APIでのコメント生成に失敗:'), error.message); + return `**${user.displayName} が新たな境地へ到達しました!**`; + } +} + +async function getLevelData(db, guildId, userId) { + const userRef = doc(db, 'levels', `${guildId}_${userId}`); + const docSnap = await getDoc(userRef); + if (docSnap.exists()) { + const data = docSnap.data(); + return { + level: 0, + xp: 0, + messageCount: 0, + lastMessageTimestamp: 0, + ...data + }; + } + return { + guildId, + userId, + xp: 0, + level: 0, + messageCount: 0, + lastMessageTimestamp: 0 + }; +} + +async function getRank(db, guildId, userId) { + try { + const usersRef = collection(db, 'levels'); + const q = query(usersRef, where('guildId', '==', guildId), orderBy('level', 'desc'), orderBy('xp', 'desc')); + const snapshot = await getDocs(q); + let rank = -1; + snapshot.docs.forEach((doc, index) => { + if (doc.data().userId === userId) { + rank = index + 1; + } + }); + return rank; + } catch (error) { + console.error('Error getting rank:', error); + return -1; + } +} + +async function handleRoleRewards(member, oldLevel, newLevel, settings) { + const levelingSettings = settings.leveling || {}; + const roleRewards = levelingSettings.roleRewards || []; + if (roleRewards.length === 0) return; + + const rewardsToGive = roleRewards + .filter(reward => reward.level > oldLevel && reward.level <= newLevel) + .sort((a, b) => a.level - b.level); + + if (rewardsToGive.length === 0) return; + + if (!member.guild.members.me.permissions.has(PermissionsBitField.Flags.ManageRoles)) { + console.error(chalk.red(`[Role Reward] Bot does not have Manage Roles permission.`)); + return; + } + + let awardedRoles = []; + for (const reward of rewardsToGive) { + try { + const role = member.guild.roles.cache.get(reward.roleId); + if (!role) continue; + if (role.position >= member.guild.members.me.roles.highest.position) continue; + + if (!member.roles.cache.has(role.id)) { + await member.roles.add(role); + awardedRoles.push(role); + } + } catch (error) { + console.error(chalk.red(`[Role Reward] Failed to award role:`), error); + } + } + return awardedRoles; +} + +module.exports = { + calculateRequiredXp, + generateLevelUpComment, + getLevelData, + getRank, + handleRoleRewards +}; diff --git a/src/services/rankboardService.js b/src/services/rankboardService.js new file mode 100644 index 0000000..8a4291b --- /dev/null +++ b/src/services/rankboardService.js @@ -0,0 +1,119 @@ +const { EmbedBuilder } = require('discord.js'); +const { collection, query, where, getDocs, orderBy, limit } = require('firebase/firestore'); +const { ref, get } = require('firebase/database'); +const chalk = require('chalk'); +const { calculateRequiredXp } = require('./levelingService'); + +async function updateRankboards(client) { + if (!client.isReady()) return; + console.log(chalk.cyan('[Rankboard] Starting periodic update...')); + const db = client.db; + const rtdb = client.rtdb; + const settingsCol = collection(db, 'guild_settings'); + const q = query(settingsCol, where('rankBoard', '!=', null)); + + try { + const snapshot = await getDocs(q); + if (snapshot.empty) { + console.log(chalk.cyan('[Rankboard] No active rankboards found.')); + return; + } + + for (const guildSettingsDoc of snapshot.docs) { + const settings = guildSettingsDoc.data(); + const guildId = guildSettingsDoc.id; + const rankBoardConfig = settings.rankBoard; + + if (!rankBoardConfig || !rankBoardConfig.channelId || !rankBoardConfig.messageId) { + continue; + } + + const guild = await client.guilds.fetch(guildId).catch(err => { + console.error(chalk.red(`[Rankboard] Failed to fetch guild ${guildId}`), err); + return null; + }); + if (!guild) continue; + + try { + const levelsRef = collection(db, 'levels'); + const levelQuery = query( + levelsRef, + where('guildId', '==', guildId), + orderBy('level', 'desc'), + orderBy('xp', 'desc'), + limit(10) + ); + const levelSnapshot = await getDocs(levelQuery); + const userStats = []; + levelSnapshot.forEach(doc => { + const data = doc.data(); + userStats.push({ + userId: data.userId, + level: data.level || 0, + xp: data.xp || 0, + }); + }); + + const allSessionsRef = ref(rtdb, `voiceSessions/${guild.id}`); + const allSessionsSnapshot = await get(allSessionsRef); + const onlineUsers = allSessionsSnapshot.exists() ? allSessionsSnapshot.val() : {}; + + const finalStats = userStats.map(stat => { + let currentXp = stat.xp; + if (onlineUsers[stat.userId]) { + const sessionDurationMs = Date.now() - onlineUsers[stat.userId].joinedAt; + const minutesStayed = Math.floor(sessionDurationMs / 60000); + const vcXpGained = minutesStayed * 5; + currentXp += vcXpGained; + } + return { ...stat, finalXp: currentXp }; + }); + + finalStats.sort((a, b) => { + if (b.level !== a.level) { + return b.level - a.level; + } + return b.finalXp - a.finalXp; + }); + + const rankEmbed = new EmbedBuilder() + .setColor(0x00FFFF) + .setTitle(`🏆 ${guild.name} リアルタイムランキング`) + .setThumbnail(guild.iconURL({ dynamic: true })) + .setTimestamp() + .setFooter({ text: '🟢: VC参加中 | 5分ごとに更新' }); + + if (finalStats.length === 0) { + rankEmbed.setDescription('まだデータがありません。'); + } else { + const rankPromises = finalStats.map(async (stat, index) => { + const member = await guild.members.fetch(stat.userId).catch(() => null); + const medal = ['🥇', '🥈', '🥉'][index] || `**#${index + 1}**`; + const requiredXp = calculateRequiredXp(stat.level); + const isOnline = onlineUsers[stat.userId] ? '🟢' : ''; + + return `${medal} ${isOnline} **${member ? member.displayName : '不明なユーザー'}**\n> LV: \`${stat.level}\` | XP: \`${stat.finalXp.toLocaleString()} / ${requiredXp.toLocaleString()}\``; + }); + const rankStrings = await Promise.all(rankPromises); + rankEmbed.setDescription(rankStrings.join('\n\n')); + } + + const channel = await client.channels.fetch(rankBoardConfig.channelId).catch(() => null); + if (channel) { + const message = await channel.messages.fetch(rankBoardConfig.messageId).catch(() => null); + if (message) { + await message.edit({ embeds: [rankEmbed] }); + } + } + } catch (error) { + console.error(chalk.red(`[Rankboard] Error updating board for guild ${guildId}:`), error); + } + } + } catch (error) { + console.error(chalk.red('[Rankboard] Failed to query for guild settings:'), error); + } +} + +module.exports = { + updateRankboards +}; diff --git a/src/services/statusService.js b/src/services/statusService.js new file mode 100644 index 0000000..8aeee65 --- /dev/null +++ b/src/services/statusService.js @@ -0,0 +1,104 @@ +const { ActivityType } = require('discord.js'); +const { doc, getDoc, setDoc } = require('firebase/firestore'); +const chalk = require('chalk'); + +async function generateAIStatus(client) { + try { + const userCount = client.guilds.cache.reduce((a, g) => a + g.memberCount, 0); + const prompt = `あなたは「OrderlyCore」という名前のDiscordボットです。あなたの現在のユニークで面白いステータスを生成してください。 + +# 指示 +- サーバー数 (${client.guilds.cache.size}個) や、総ユーザー数 (${userCount}人) などの動的な情報を含めることができます。 +- 短く、キャッチーで、少しユーモラスなものが望ましいです。 +- 必ずJSON形式で {"emoji": "絵文字", "state": "ステータスメッセージ"} の形式で出力してください。 +- ステータスメッセージは30文字以内にしてください。`; + + const result = await client.geminiModel.generateContent(prompt); + const text = result.response.text().replace(/```json|```/g, '').trim(); + return JSON.parse(text); + } catch (error) { + console.error(chalk.red('❌ Geminiによるステータス生成に失敗:'), error); + return { emoji: '⚠️', state: 'AI思考エラー' }; + } +} + +async function loadStatusSettings(db) { + try { + const settingsRef = doc(db, 'bot_settings', 'statuses'); + const docSnap = await getDoc(settingsRef); + + if (docSnap.exists() && docSnap.data().list) { + return { + list: docSnap.data().list, + mode: docSnap.data().mode || 'custom' + }; + } else { + const defaultStatuses = [ + { emoji: '✅', state: '正常稼働中' }, + { emoji: '💡', state: '/help でコマンド一覧' }, + { emoji: '🛡️', state: '${serverCount} サーバーを保護中' }, + ]; + await setDoc(settingsRef, { list: defaultStatuses, mode: 'custom' }); + return { list: defaultStatuses, mode: 'custom' }; + } + } catch (error) { + console.error(chalk.red('❌ Firestoreからのステータス読み込みに失敗:'), error.message); + return { list: [{ emoji: '❌', state: 'ステータス読込エラー' }], mode: 'custom' }; + } +} + +let statusInterval = null; + +function startStatusRotation(client) { + if (statusInterval) { + clearInterval(statusInterval); + } + + let i = 0; + const updateStatus = async () => { + if (!client.isReady()) return; + + const { list: dynamicStatuses, mode: statusMode } = await loadStatusSettings(client.db); + let statusToShow; + + if (statusMode === 'ai') { + statusToShow = await generateAIStatus(client); + } else { + if (dynamicStatuses && dynamicStatuses.length > 0) { + const statusTemplate = dynamicStatuses[i]; + const statusState = statusTemplate.state + .replace(/\$\{serverCount\}/g, client.guilds.cache.size) + .replace(/\$\{userCount\}/g, client.guilds.cache.reduce((a, g) => a + g.memberCount, 0)); + statusToShow = { emoji: statusTemplate.emoji, state: statusState }; + i = (i + 1) % dynamicStatuses.length; + } else { + statusToShow = { emoji: '🔧', state: 'ステータス設定待ち' }; + } + } + + if (statusToShow) { + try { + await client.user.setPresence({ + activities: [{ + name: 'customstatus', + type: ActivityType.Custom, + state: statusToShow.state, + emoji: statusToShow.emoji + }], + status: 'online' + }); + } catch (error) { + console.error(chalk.red('[Status Rotator] Failed to set presence:'), error); + } + } + }; + + updateStatus(); + statusInterval = setInterval(updateStatus, 60000); +} + +module.exports = { + generateAIStatus, + loadStatusSettings, + startStatusRotation +}; diff --git a/src/services/welcomeService.js b/src/services/welcomeService.js new file mode 100644 index 0000000..c9dad49 --- /dev/null +++ b/src/services/welcomeService.js @@ -0,0 +1,48 @@ +const chalk = require('chalk'); + +async function generateWelcomeWithGemini(client, member) { + const { user, guild } = member; + try { + const prompt = `あなたはDiscordサーバーの歓迎担当AIです。新しく参加したユーザーを温かく、そしてクリエイティブに歓迎するメッセージを作成してください。 + +# 指示 +- ポジティブで、歓迎の意が伝わるフレンドリーな文章を生成してください。 +- 以下の情報を文章に必ず含めてください。 + - ユーザー名: ${user.displayName} + - サーバー名: ${guild.name} + - 現在のメンバー数: ${guild.memberCount} +- 生成する文章は必ず**タイトル**と**説明文**の2つの部分に分けてください。 +- タイトルは「🎉」や「ようこそ!」などの絵文字を含んだ短いフレーズにしてください。(20文字以内) +- 説明文は、ユーザーへの呼びかけから始まり、サーバーの簡単な紹介や、これから始まる素晴らしい体験への期待感を抱かせるような、少し長めの文章にしてください。(150文字以内) +- 必ずJSON形式で、{"title": "生成したタイトル", "description": "生成した説明文"} の形式で出力してください。`; + + const result = await client.geminiModel.generateContent(prompt); + const text = result.response.text().replace(/```json|```/g, '').trim(); + return JSON.parse(text); + } catch (error) { + console.error('❌ Geminiでのウェルカムメッセージ生成エラー:', error); + return { + title: `🎉 ${guild.name}へようこそ!`, + description: `**${user.displayName}**さん、サーバーへのご参加ありがとうございます!これから一緒に楽しみましょう!` + }; + } +} + +function replacePlaceholders(text, member, rulesChannelId) { + const { user, guild } = member; + const rulesChannel = rulesChannelId ? `<#${rulesChannelId}>` : 'ルールチャンネル'; + + return text + .replace(/{user.name}/g, user.username) + .replace(/{user.tag}/g, user.tag) + .replace(/{user.displayName}/g, user.displayName) + .replace(/{user.mention}/g, `<@${user.id}>`) + .replace(/{server.name}/g, guild.name) + .replace(/{server.memberCount}/g, guild.memberCount.toLocaleString()) + .replace(/{rulesChannel}/g, rulesChannel); +} + +module.exports = { + generateWelcomeWithGemini, + replacePlaceholders +}; diff --git a/src/utils/embedBuilder.js b/src/utils/embedBuilder.js new file mode 100644 index 0000000..ec2ec6e --- /dev/null +++ b/src/utils/embedBuilder.js @@ -0,0 +1,52 @@ +const { EmbedBuilder } = require('discord.js'); + +/** + * Creates a standard embed with a consistent theme. + * @param {Object} options + * @param {string} options.title + * @param {string} [options.description] + * @param {number|string} [options.color] + * @param {Object} [options.author] + * @param {Array} [options.fields] + * @param {string} [options.thumbnail] + * @param {string} [options.image] + * @param {Object} [options.footer] + * @param {boolean} [options.timestamp=true] + * @returns {EmbedBuilder} + */ +function createStandardEmbed(options) { + const embed = new EmbedBuilder() + .setTitle(options.title || null) + .setDescription(options.description || null) + .setColor(options.color || 0x5865F2); // Discord Blurple as default + + if (options.author) { + embed.setAuthor(options.author); + } + + if (options.fields && options.fields.length > 0) { + embed.addFields(options.fields); + } + + if (options.thumbnail) { + embed.setThumbnail(options.thumbnail); + } + + if (options.image) { + embed.setImage(options.image); + } + + if (options.footer) { + embed.setFooter(options.footer); + } + + if (options.timestamp !== false) { + embed.setTimestamp(); + } + + return embed; +} + +module.exports = { + createStandardEmbed +}; diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..be63f17 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,33 @@ +const chalk = require('chalk'); + +async function keepAlive() { + const PING_INTERVAL = 2 * 60 * 1000; + const appUrl = process.env.APP_URL; + + if (!appUrl) { + console.warn(chalk.yellow('⚠️ APP_URL is not set. Keep-alive is disabled.')); + return; + } + + setInterval(async () => { + try { + const url = appUrl.endsWith('/ping') ? appUrl : `${appUrl}/ping`; + // Use global fetch (Node.js 18+) + const response = await fetch(url, { + headers: { 'User-Agent': 'OrderlyCore-Bot/1.0.0' } + }); + + if (response.ok) { + console.log(chalk.blueBright(`[Keep-Alive] Ping successful to ${url}.`)); + } else { + console.error(chalk.yellow(`[Keep-Alive] Ping failed to ${url}. Status: ${response.status}`)); + } + } catch (error) { + console.error(chalk.red(`[Keep-Alive] Error pinging ${appUrl}:`, error.message)); + } + }, PING_INTERVAL); +} + +module.exports = { + keepAlive +};