diff --git a/events/interactionCreate.js b/events/interactionCreate.js index aa09925..581c8df 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -1,4 +1,30 @@ const chalk = require('chalk'); +const { doc, getDoc } = require('firebase/firestore'); + +// In-memory cache to prevent excessive DB reads +const globalCache = { + maintenance: { data: null, lastFetch: 0 }, + blacklist: { data: null, lastFetch: 0 }, + guildSettings: new Map() // guildId -> { data, lastFetch } +}; + +const CACHE_TTL = 60 * 1000; // 1 minute + +async function getCachedDoc(db, collection, id, cacheObj) { + const now = Date.now(); + if (cacheObj.data && (now - cacheObj.lastFetch < CACHE_TTL)) { + return cacheObj.data; + } + try { + const snap = await getDoc(doc(db, collection, id)); + cacheObj.data = snap.exists() ? snap.data() : null; + cacheObj.lastFetch = now; + return cacheObj.data; + } catch (err) { + console.error(chalk.red(`[CACHE ERROR] Failed to fetch ${collection}/${id}:`), err); + return cacheObj.data; // Return stale data on error + } +} module.exports = { name: 'interactionCreate', @@ -7,7 +33,52 @@ module.exports = { if (!interaction.isChatInputCommand() && !interaction.isAutocomplete() && !interaction.isModalSubmit()) return; + // 1. Global Checks (Maintenance & Blacklist) + const maintenanceData = await getCachedDoc(client.db, 'bot_settings', 'maintenance', globalCache.maintenance); + const blacklistData = await getCachedDoc(client.db, 'bot_settings', 'blacklist', globalCache.blacklist); + + // Blacklist check + if (blacklistData && blacklistData.users?.includes(interaction.user.id)) { + const message = { content: '[ERR] あなたはボットの使用を制限されています。', ephemeral: true }; + return interaction.isAutocomplete() ? null : interaction.reply(message); + } + + // Maintenance mode check (Bypass for Bot Owners) + if (maintenanceData && maintenanceData.enabled) { + // Get bot owners from application + if (!client.application.owner) await client.application.fetch(); + const owners = client.application.owner.members + ? client.application.owner.members.map(m => m.id) + : [client.application.owner.id]; + + if (!owners.includes(interaction.user.id)) { + const reason = maintenanceData.reason || '現在メンテナンス中です。'; + const message = { content: `[INFO] ${reason}`, ephemeral: true }; + return interaction.isAutocomplete() ? null : interaction.reply(message); + } + } + if (interaction.isChatInputCommand()) { + // 2. Guild-specific Command Check + if (interaction.guildId) { + if (!globalCache.guildSettings.has(interaction.guildId)) { + globalCache.guildSettings.set(interaction.guildId, { data: null, lastFetch: 0 }); + } + const guildSettings = await getCachedDoc( + client.db, + 'guild_settings', + interaction.guildId, + globalCache.guildSettings.get(interaction.guildId) + ); + + if (guildSettings && guildSettings.disabledCommands?.includes(interaction.commandName)) { + return interaction.reply({ + content: `[INFO] このコマンド「${interaction.commandName}」はこのサーバーで無効化されています。`, + ephemeral: true + }); + } + } + const command = client.commands.get(interaction.commandName); if (!command) { console.error(chalk.red(`[ERROR] Unknown command: ${interaction.commandName}`)); diff --git a/package-lock.json b/package-lock.json index d5db253..c7b7c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@discord-player/extractor/-/extractor-7.1.0.tgz", "integrity": "sha512-/ttNFkN0hacSS/KJNcPP8Dvk1W8+QGbdlbtJNIPHO1oBfEMazs6BimokMG5eCVmSLPb2MaWPGKTjhoQzHLlBlw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "^16.5.4", "isomorphic-unfetch": "^4.0.2", @@ -213,7 +212,6 @@ "integrity": "sha512-HHEnSNrSPmFEyndRdQBJN2YE6egyXS9JUnJWyP6jficK0Y+qKMEZXyYTgmzpjrxXP1exM/hKaNP7BRBUEWkU5w==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@discordjs/node-pre-gyp": "^0.4.5", "node-addon-api": "^8.1.0" @@ -274,7 +272,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.0.tgz", "integrity": "sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/ws": "^8.18.1", "discord-api-types": "^0.38.16", @@ -839,7 +836,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.13.tgz", "integrity": "sha512-OZiDAEK/lDB6xy/XzYAyJJkaDqmQ+BCtOEPLqFvxWKUz5JbBmej7IiiRHdtiIOD/twW7O5AxVsfaaGA/V1bNsA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.6.9", "@firebase/logger": "0.4.2", @@ -897,7 +893,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.43.tgz", "integrity": "sha512-HM96ZyIblXjAC7TzE8wIk2QhHlSvksYkQ4Ukh1GmEenzkucSNUmUX4QvoKrqeWsLEQ8hdcojABeCV8ybVyZmeg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.10.13", "@firebase/component": "0.6.9", @@ -910,8 +905,7 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.7.9", @@ -1377,7 +1371,6 @@ "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -2934,7 +2927,6 @@ "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.22.1.tgz", "integrity": "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", @@ -2962,7 +2954,6 @@ "resolved": "https://registry.npmjs.org/distube/-/distube-5.0.7.tgz", "integrity": "sha512-EyxXH2q+SGIgdtKgDPaGvQe9Tyce7nMfps6FV1mt6EUDQg1ld1I2NrLsugCUHaelDpG7zG950dFvv6xryRnMuA==", "license": "MIT", - "peer": true, "dependencies": { "tiny-typed-emitter": "^2.1.0", "undici": "^7.7.0" @@ -3176,7 +3167,6 @@ "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3446,7 +3436,6 @@ "integrity": "sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==", "hasInstallScript": true, "license": "GPL-3.0-or-later", - "peer": true, "dependencies": { "@derhuerst/http-basic": "^8.2.0", "env-paths": "^2.2.0", @@ -4408,6 +4397,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4424,6 +4414,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4440,6 +4431,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4453,6 +4445,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4469,6 +4462,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4485,6 +4479,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4501,6 +4496,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4517,6 +4513,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4533,6 +4530,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4549,6 +4547,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4565,6 +4564,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4581,6 +4581,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4597,6 +4598,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4613,6 +4615,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4629,6 +4632,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -4645,6 +4649,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -6167,7 +6172,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6364,7 +6368,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index c7ac166..3cde43d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@google/generative-ai": "^0.2.1", "@napi-rs/canvas": "^0.1.77", "chalk": "^4.1.2", + "chart.js": "^4.4.2", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "discord-player": "^7.1.0", diff --git a/public/admin-login.html b/public/admin-login.html index a2cf364..1df73c9 100644 --- a/public/admin-login.html +++ b/public/admin-login.html @@ -21,7 +21,6 @@ --font-mono: 'Share Tech Mono', monospace; } - @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes blink { 50% { opacity: 0; } } @@ -41,7 +40,6 @@ .login-container { width: 100%; max-width: 500px; - animation: fadeIn 0.7s ease-out; } .terminal { diff --git a/public/admin.css b/public/admin.css index 71c1b7d..4bcbf44 100644 --- a/public/admin.css +++ b/public/admin.css @@ -148,7 +148,6 @@ body { padding: 12px 20px; color: var(--text-muted-color); text-decoration: none; - transition: all 0.3s; border-left: 3px solid transparent; } @@ -234,12 +233,6 @@ body { height: 8px; background-color: var(--success-color); border-radius: 50%; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } } #page-content-wrapper { @@ -260,12 +253,6 @@ body { border-radius: 8px; padding: 25px; margin-bottom: 25px; - transition: transform 0.3s, box-shadow 0.3s; -} - -.card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .card-header { diff --git a/public/admin.html b/public/admin.html index 04a8750..893d26f 100644 --- a/public/admin.html +++ b/public/admin.html @@ -45,7 +45,16 @@
情報の読み込みに失敗しました。再ログインしてください。
ログインページへ`; + } + }, + + loadPage: async (pageName) => { + try { + clearIntervals(); + navItems.forEach(item => item.classList.remove('active')); + const activeItem = document.querySelector(`[data-page="${pageName}"]`); + if (activeItem) activeItem.classList.add('active'); + + pageContent.innerHTML = ''; + + if (App.renderers[pageName]) { + await App.renderers[pageName](); + } else { + pageContent.innerHTML = 'ページが見つかりません
'; + } + feather.replace(); + } catch (error) { + pageContent.innerHTML = `エラー: ${error.message}
本当にログアウトしますか?
', + [ + { id: 'cancel-logout', text: 'キャンセル', class: 'btn-secondary' }, + { id: 'confirm-logout', text: 'ログアウト', class: 'btn-danger' } + ] + ); + document.getElementById('cancel-logout').onclick = closeModal; + document.getElementById('confirm-logout').onclick = async () => { + try { + await api.post('/api/admin/logout'); + window.location.href = '/admin-login.html'; + } catch (err) { + showMessage('ログアウトに失敗しました', 'error'); + } + }; + }); + + menuToggle.addEventListener('click', () => { + sidebar.classList.toggle('is-open'); + }); + + document.getElementById('global-user-search').addEventListener('keypress', async (e) => { + if (e.key === 'Enter') { + const userId = e.target.value.trim(); + if (!userId) return; + try { + showMessage(`ユーザーID ${userId} を検索中...`, 'info'); + const data = await api.get(`/api/admin/user-search?userId=${userId}`); + + createModal('ユーザー検索結果', ` +所属サーバー (${data.guilds.length}):
+${guild.id}
- ${escapeHTML(guild.id)}
サーバーが見つかりません
'; + + document.getElementById('page-info').textContent = `Page ${data.currentPage} of ${data.totalPages}`; + document.getElementById('prev-page').disabled = data.currentPage <= 1; + document.getElementById('next-page').disabled = data.currentPage >= data.totalPages; + feather.replace(); + } catch (e) { + listEl.innerHTML = '読み込み失敗
'; + } + }; - // 検索機能 - document.getElementById('server-search').addEventListener('input', (e) => { - const searchTerm = e.target.value.toLowerCase(); - document.querySelectorAll('.server-card').forEach(card => { - const serverName = card.dataset.serverName; - card.style.display = serverName.includes(searchTerm) ? 'block' : 'none'; - }); + document.getElementById('server-search').addEventListener('input', () => { + currentPage = 1; + renderServers(); }); + document.getElementById('prev-page').onclick = () => { currentPage--; renderServers(); }; + document.getElementById('next-page').onclick = () => { currentPage++; renderServers(); }; - feather.replace(); + await renderServers(); }, announcements: async () => { @@ -443,15 +596,19 @@ document.addEventListener('DOMContentLoaded', async () => { `; // ログ更新機能 - let logCount = 4; document.getElementById('refresh-logs').addEventListener('click', () => { const logViewer = document.getElementById('log-viewer'); const newLog = document.createElement('p'); newLog.style.color = 'var(--success-color)'; newLog.textContent = `[${new Date().toLocaleTimeString()}] [INFO] ログ更新 - システム正常`; logViewer.appendChild(newLog); + + // Limit logs to prevent infinite growth + while (logViewer.children.length > 50) { + logViewer.removeChild(logViewer.firstChild); + } + logViewer.scrollTop = logViewer.scrollHeight; - logCount++; }); document.getElementById('clear-logs').addEventListener('click', () => { @@ -471,6 +628,193 @@ document.addEventListener('DOMContentLoaded', async () => { feather.replace(); }, + maintenance: async () => { + pageTitle.textContent = 'メンテナンス設定'; + pageSubtitle.textContent = 'ボット全体のメンテナンスモードを管理'; + + const status = await api.get('/api/admin/maintenance'); + + pageContent.innerHTML = ` +| ユーザーID | アクション |
|---|---|
| ${id} | ++ |
| ブラックリストは空です。 | |
| 時刻 | +メモリ (MB) | +Ping (ms) | +
|---|
ページが見つかりません
'; - } - } catch (error) { - pageContent.innerHTML = `エラー: ${error.message}
本当にログアウトしますか?
', - [ - { id: 'cancel-logout', text: 'キャンセル', class: 'btn-secondary' }, - { id: 'confirm-logout', text: 'ログアウト', class: 'btn-danger' } - ] - ); - document.getElementById('cancel-logout').onclick = closeModal; - document.getElementById('confirm-logout').onclick = async () => { - try { - await api.post('/api/admin/logout'); - window.location.href = '/admin-login.html'; - } catch (err) { - showMessage('ログアウトに失敗しました', 'error'); - } - }; - }); - - menuToggle.addEventListener('click', () => { - sidebar.classList.toggle('is-open'); - }); - - // Load initial page - const hash = window.location.hash.slice(1) || 'servers'; - loadPage(hash); - - } catch (error) { - loader.innerHTML = `情報の読み込みに失敗しました。再ログインしてください。
ログインページへ`; - } - }; + } +}; - init(); + window.App = App; + App.init(); }); diff --git a/public/client.js b/public/client.js index 2ef51e2..164ed05 100644 --- a/public/client.js +++ b/public/client.js @@ -180,82 +180,301 @@ document.addEventListener('DOMContentLoaded', async () => { console.log('isDirty reset to false.'); }; - // --- Page Rendering --- - const renderers = { - dashboard: async () => { + // --- Dashboard Application Module --- + const escapeHTML = (str) => { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + const App = { + state: { + currentPage: 'dashboard', + isInitialLoad: true + }, + + init: async () => { + try { + guildInfo = await api.get('/api/guild-info'); + document.getElementById('server-icon').src = guildInfo.icon || 'https://cdn.discordapp.com/embed/avatars/0.png'; + document.getElementById('server-name').textContent = guildInfo.name; + + loader.style.display = 'none'; + dashboardWrapper.style.display = 'flex'; + + window.addEventListener('hashchange', App.handleRoute, false); + await App.handleRoute(); + + App.bindGlobalEvents(); + } catch (error) { + console.error('App init error:', error); + loader.innerHTML = `ログインページへ`; + } + }, + + handleRoute: async (event) => { + const proceed = async () => { + resetDirtyState(); + if (window.innerWidth <= 768) { + sidebar.classList.remove('is-open'); + } + + const page = window.location.hash.substring(1) || 'dashboard'; + App.state.currentPage = page; + + navItems.forEach(item => item.classList.toggle('active', item.dataset.page === page)); + pageContent.innerHTML = ''; + + if (App.renderers[page]) { + try { + await App.renderers[page](); + } catch (error) { + console.error(`Render error on ${page}:`, error); + pageContent.innerHTML = ``; + } + } else { + pageContent.innerHTML = ``; + } + + feather.replace(); + window.scrollTo(0, 0); + }; + + if (isDirty) { + if (event && event.type === 'hashchange') { + event.preventDefault(); + const oldHash = event.oldURL.split('#')[1] || 'dashboard'; + history.pushState(null, null, `#${oldHash}`); + } + + createModal('未保存の変更があります', + 'ページを移動すると、保存されていない変更は失われます。本当に移動しますか?
', + [ + { id: 'cancel-nav', text: 'キャンセル', class: 'btn-secondary' }, + { id: 'confirm-nav', text: '移動する', class: 'btn-danger' } + ] + ); + document.getElementById('cancel-nav').onclick = closeModal; + document.getElementById('confirm-nav').onclick = () => { + closeModal(); + if (event) { + const newHash = new URL(event.newURL).hash; + window.location.hash = newHash; + } + proceed(); + }; + } else { + proceed(); + } + }, + + bindGlobalEvents: () => { + logoutBtn.addEventListener('click', () => { + createModal('ログアウトの確認', + '本当にログアウトしますか?
', + [ + { id: 'cancel-logout', text: 'キャンセル', class: 'btn-secondary' }, + { id: 'confirm-logout', text: 'ログアウト', class: 'btn-danger' } + ] + ); + document.getElementById('cancel-logout').onclick = closeModal; + document.getElementById('confirm-logout').onclick = async () => { + try { + isDirty = false; + await api.post('/api/logout'); + window.location.href = '/login'; + } catch (err) { + showMessage('ログアウトに失敗しました', 'error'); + } + }; + }); + + menuToggle.addEventListener('click', () => { + sidebar.classList.toggle('is-open'); + }); + + window.addEventListener('beforeunload', (e) => { + if (isDirty) { + e.preventDefault(); + e.returnValue = ''; + return ''; + } + }); + }, + + renderers: { + dashboard: async () => { pageTitle.textContent = 'ダッシュボード'; - const settings = await api.get('/api/settings/guilds'); + pageContent.innerHTML = ''; + const [settings, trends] = await Promise.all([ + api.get('/api/settings/guilds'), + api.get('/api/analytics/trends') + ]); + + const recentLogs = await api.get('/api/audit-logs?limit=5'); + pageContent.innerHTML = ` -| 日時 | +アクション | +ユーザー | +
|---|---|---|
| ${new Date(log.timestamp.seconds * 1000).toLocaleString()} | +${escapeHTML(log.eventType)} | +${escapeHTML(log.executorTag || 'System')} | +
| 表示可能なログはありません。 | ||