require('dotenv').config({ path: __dirname + '/.env' }); const express = require('express'); const cors = require('cors'); const app = express(); const port = process.env.PORT || 3001; const host = process.env.HOST || '0.0.0.0'; const corsOrigin = process.env.CORS_ORIGIN || null; // e.g. 'http://example.com' or '*' or 'http://127.0.0.1:3000' // Convenience base URLs (override if you want fully-qualified URLs) const BACKEND_BASE = process.env.BACKEND_BASE || `http://${host}:${port}`; const FRONTEND_BASE = process.env.FRONTEND_BASE || 'http://localhost:3000'; if (corsOrigin) { app.use(cors({ origin: corsOrigin })); } else { app.use(cors()); } app.use(express.json()); const axios = require('axios'); const crypto = require('crypto'); // Twitch API helpers (uses TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET from env) let _twitchToken = null; let _twitchTokenExpiry = 0; async function getTwitchAppToken() { if (_twitchToken && Date.now() < _twitchTokenExpiry - 60000) return _twitchToken; const id = process.env.TWITCH_CLIENT_ID; const secret = process.env.TWITCH_CLIENT_SECRET; if (!id || !secret) return null; try { const resp = await axios.post('https://id.twitch.tv/oauth2/token', null, { params: { client_id: id, client_secret: secret, grant_type: 'client_credentials' }, }); _twitchToken = resp.data.access_token; const expiresIn = Number(resp.data.expires_in) || 3600; _twitchTokenExpiry = Date.now() + expiresIn * 1000; return _twitchToken; } catch (e) { console.error('Failed to fetch Twitch app token:', e && e.response && e.response.data ? e.response.data : e.message || e); return null; } } async function getTwitchStreamsForUsers(usernames = []) { try { if (!usernames || usernames.length === 0) return []; const token = await getTwitchAppToken(); const id = process.env.TWITCH_CLIENT_ID; const params = new URLSearchParams(); for (const u of usernames) params.append('user_login', u.toLowerCase()); const headers = {}; if (id) headers['Client-Id'] = id; if (token) headers['Authorization'] = `Bearer ${token}`; const url = `https://api.twitch.tv/helix/streams?${params.toString()}`; const resp = await axios.get(url, { headers }); // resp.data.data is an array of live streams const live = resp.data && resp.data.data ? resp.data.data : []; // Map by user_login // Fetch user info (bio, profile image) so we can include it in the response const uniqueLogins = Array.from(new Set(usernames.map(u => (u || '').toLowerCase()))).filter(Boolean); const users = {}; if (uniqueLogins.length > 0) { const userParams = new URLSearchParams(); for (const u of uniqueLogins) userParams.append('login', u); try { const usersUrl = `https://api.twitch.tv/helix/users?${userParams.toString()}`; const usersResp = await axios.get(usersUrl, { headers }); const usersData = usersResp.data && usersResp.data.data ? usersResp.data.data : []; for (const u of usersData) { users[(u.login || '').toLowerCase()] = { id: u.id, login: u.login, display_name: u.display_name, description: u.description, profile_image_url: u.profile_image_url, }; } } catch (e) { // ignore user fetch errors } } // collect game ids from live streams to resolve game names const gameIds = Array.from(new Set((live || []).map(s => s.game_id).filter(Boolean))); const games = {}; if (gameIds.length > 0) { try { const gamesParams = new URLSearchParams(); for (const idv of gameIds) gamesParams.append('id', idv); const gamesUrl = `https://api.twitch.tv/helix/games?${gamesParams.toString()}`; const gamesResp = await axios.get(gamesUrl, { headers }); const gamesData = gamesResp.data && gamesResp.data.data ? gamesResp.data.data : []; for (const g of gamesData) { games[g.id] = g.name; } } catch (e) { // ignore } } // Build response for every requested username (include user info even when offline) const map = {}; for (const u of uniqueLogins) { map[u] = { is_live: false, user_login: u, user_name: (users[u] && users[u].display_name) || u, title: null, viewer_count: 0, started_at: null, url: `https://www.twitch.tv/${u}`, thumbnail_url: null, description: (users[u] && users[u].description) || null, profile_image_url: (users[u] && users[u].profile_image_url) || null, game_name: null, }; } for (const s of live) { const login = (s.user_login || '').toLowerCase(); // twitch returns thumbnail_url like ".../{width}x{height}.jpg" — replace with a sensible size const rawThumb = s.thumbnail_url || null; const thumb = rawThumb ? rawThumb.replace('{width}', '1280').replace('{height}', '720') : null; map[login] = { is_live: true, user_login: s.user_login, user_name: s.user_name, title: s.title, viewer_count: s.viewer_count, started_at: s.started_at, url: `https://www.twitch.tv/${s.user_login}`, thumbnail_url: thumb, description: (users[login] && users[login].description) || null, profile_image_url: (users[login] && users[login].profile_image_url) || null, game_name: (s.game_id && games[s.game_id]) || null, }; } return Object.values(map); } catch (e) { console.error('Error fetching twitch streams:', e && e.response && e.response.data ? e.response.data : e.message || e); return []; } } // Proxy endpoint for frontend/bot to request stream status for usernames (comma separated) app.get('/api/twitch/streams', async (req, res) => { const q = req.query.users || req.query.user || ''; const users = q.split(',').map(s => (s || '').trim()).filter(Boolean); try { const streams = await getTwitchStreamsForUsers(users); res.json(streams); } catch (err) { console.error('Error in /api/twitch/streams:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Kick API helpers (web scraping since no public API) async function getKickStreamsForUsers(usernames = []) { try { if (!usernames || usernames.length === 0) return []; const results = []; for (const username of usernames) { try { // Use Kick's API endpoint to check if user is live const url = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`; const response = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept': 'application/json', 'Referer': 'https://kick.com/' }, timeout: 5000 // 5 second timeout }); if (response.status === 200 && response.data) { const data = response.data; if (data.livestream && data.livestream.is_live) { results.push({ is_live: true, user_login: username, user_name: data.user?.username || username, title: data.livestream.session_title || `${username} is live`, viewer_count: data.livestream.viewer_count || 0, started_at: data.livestream.start_time, url: `https://kick.com/${username}`, thumbnail_url: data.livestream.thumbnail?.url || null, category: data.category?.name || 'Unknown', description: data.user?.bio || '' }); } else { // User exists but not live results.push({ is_live: false, user_login: username, user_name: data.user?.username || username, title: null, viewer_count: 0, started_at: null, url: `https://kick.com/${username}`, thumbnail_url: null, category: null, description: data.user?.bio || '' }); } } else { // User not found or API error results.push({ is_live: false, user_login: username, user_name: username, title: null, viewer_count: 0, started_at: null, url: `https://kick.com/${username}`, thumbnail_url: null, category: null, description: '' }); } } catch (e) { // If API fails with 403, try web scraping as fallback if (e.response && e.response.status === 403) { // console.log(`API blocked for ${username}, trying web scraping fallback...`); try { const pageUrl = `https://kick.com/${encodeURIComponent(username)}`; const pageResponse = await axios.get(pageUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'Accept-Encoding': 'gzip, deflate, br', 'DNT': '1', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Cache-Control': 'max-age=0' }, timeout: 5000 }); if (pageResponse.status === 200) { const html = pageResponse.data; // Check for live stream indicators in the HTML const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') || html.includes('data-is-live="true"') || html.includes('isLive:true'); if (isLive) { // Try to extract stream info from HTML let title = `${username} is live`; let viewerCount = 0; let category = 'Unknown'; // Extract title const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/); if (titleMatch) { title = titleMatch[1].replace(/\\"/g, '"'); } // Extract viewer count const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/); if (viewerMatch) { viewerCount = parseInt(viewerMatch[1]); } // Extract category const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/); if (categoryMatch) { category = categoryMatch[1]; } results.push({ is_live: true, user_login: username, user_name: username, title: title, viewer_count: viewerCount, started_at: new Date().toISOString(), url: `https://kick.com/${username}`, thumbnail_url: null, category: category, description: '' }); } else { // User exists but not live results.push({ is_live: false, user_login: username, user_name: username, title: null, viewer_count: 0, started_at: null, url: `https://kick.com/${username}`, thumbnail_url: null, category: null, description: '' }); } } else { throw e; // Re-throw if page request also fails } } catch (scrapeError) { console.error(`Web scraping fallback also failed for ${username}:`, scrapeError.message || scrapeError); // Return offline status on error results.push({ is_live: false, user_login: username, user_name: username, title: null, viewer_count: 0, started_at: null, url: `https://kick.com/${username}`, thumbnail_url: null, category: null, description: '' }); } } else { console.error(`Error checking Kick user ${username}:`, e && e.response && e.response.status ? `HTTP ${e.response.status}` : e.message || e); // Return offline status on error results.push({ is_live: false, user_login: username, user_name: username, title: null, viewer_count: 0, started_at: null, url: `https://kick.com/${username}`, thumbnail_url: null, category: null, description: '' }); } } // Small delay between requests to be respectful to Kick's servers await new Promise(r => setTimeout(r, 200)); } return results; } catch (e) { console.error('Error in getKickStreamsForUsers:', e && e.response && e.response.data ? e.response.data : e.message || e); return []; } } // Proxy endpoint for frontend/bot to request Kick stream status for usernames (comma separated) app.get('/api/kick/streams', async (req, res) => { const q = req.query.users || req.query.user || ''; const users = q.split(',').map(s => (s || '').trim()).filter(Boolean); try { const streams = await getKickStreamsForUsers(users); res.json(streams); } catch (err) { console.error('Error in /api/kick/streams:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Invite token helpers: short-lived HMAC-signed token so frontend can authorize invite deletes const INVITE_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes const inviteTokenSecret = process.env.INVITE_TOKEN_SECRET || process.env.ENCRYPTION_KEY || 'fallback-invite-secret'; function generateInviteToken(guildId) { const payload = JSON.stringify({ gid: guildId, iat: Date.now() }); const payloadB64 = Buffer.from(payload).toString('base64url'); const h = crypto.createHmac('sha256', inviteTokenSecret).update(payloadB64).digest('base64url'); return `${payloadB64}.${h}`; } function verifyInviteToken(token) { try { if (!token) return null; const parts = token.split('.'); if (parts.length !== 2) return null; const [payloadB64, sig] = parts; const expected = crypto.createHmac('sha256', inviteTokenSecret).update(payloadB64).digest('base64url'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null; const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')); if (!payload || !payload.gid || !payload.iat) return null; if (Date.now() - payload.iat > INVITE_TOKEN_TTL_MS) return null; return payload; } catch (e) { return null; } } app.get('/auth/discord', (req, res) => { const redirectUri = `${BACKEND_BASE}/auth/discord/callback`; const url = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20guilds`; res.redirect(url); }); // Provide a short-lived invite token for frontend actions (delete). Not a replacement for proper auth app.get('/api/servers/:guildId/invite-token', (req, res) => { const { guildId } = req.params; try { const token = generateInviteToken(guildId); res.json({ token }); } catch (err) { res.status(500).json({ success: false, message: 'Failed to generate token' }); } }); app.get('/auth/discord/callback', async (req, res) => { const code = req.query.code; if (!code) { return res.status(400).send('No code provided'); } try { const params = new URLSearchParams(); params.append('client_id', process.env.DISCORD_CLIENT_ID); params.append('client_secret', process.env.DISCORD_CLIENT_SECRET); params.append('grant_type', 'authorization_code'); params.append('code', code); params.append('redirect_uri', `${BACKEND_BASE}/auth/discord/callback`); const response = await axios.post('https://discord.com/api/oauth2/token', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const { access_token } = response.data; const userResponse = await axios.get('https://discord.com/api/users/@me', { headers: { Authorization: `Bearer ${access_token}`, }, }); const guildsResponse = await axios.get('https://discord.com/api/users/@me/guilds', { headers: { Authorization: `Bearer ${access_token}`, }, }); const adminGuilds = guildsResponse.data.filter(guild => (guild.permissions & 0x8) === 0x8); const user = userResponse.data; // fetch user data (theme, preferences) from Postgres try { const udata = await (require('./pg')).getUserData(user.id); user.theme = (udata && udata.theme) ? udata.theme : 'light'; } catch (e) { console.error('Error fetching user data:', e); user.theme = 'light'; } const guilds = adminGuilds; res.redirect(`${FRONTEND_BASE}/dashboard?user=${encodeURIComponent(JSON.stringify(user))}&guilds=${encodeURIComponent(JSON.stringify(guilds))}`); } catch (error) { console.error('Error during Discord OAuth2 callback:', error); res.status(500).send('Internal Server Error'); } }); const { readDb, writeDb } = require('./db'); // Require DATABASE_URL for Postgres persistence (full transition) if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is not set. The backend now requires a Postgres database. Set DATABASE_URL in backend/.env'); process.exit(1); } const pgClient = require('./pg'); pgClient.ensureSchema().catch(err => { console.error('Error ensuring PG schema:', err); process.exit(1); }); console.log('Postgres enabled for persistence'); // Simple Server-Sent Events (SSE) broadcaster const sseClients = new Map(); // key: guildId or '*' -> array of res function publishEvent(guildId, type, payload) { const enriched = Object.assign({}, payload || {}, { guildId }); const msg = `event: ${type}\ndata: ${JSON.stringify(enriched)}\n\n`; // send to guild-specific subscribers const list = sseClients.get(guildId) || []; for (const res of list.slice()) { try { res.write(msg); } catch (e) { /* ignore write errors */ } } // send to global subscribers const global = sseClients.get('*') || []; for (const res of global.slice()) { try { res.write(msg); } catch (e) { /* ignore */ } } } app.get('/api/events', (req, res) => { const guildId = req.query.guildId || '*'; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders && res.flushHeaders(); // send an initial ping res.write(`event: connected\ndata: ${JSON.stringify({ guildId })}\n\n`); if (!sseClients.has(guildId)) sseClients.set(guildId, []); sseClients.get(guildId).push(res); req.on('close', () => { const arr = sseClients.get(guildId) || []; const idx = arr.indexOf(res); if (idx !== -1) arr.splice(idx, 1); }); }); // Health endpoint used by frontend to detect backend availability app.get('/api/servers/health', async (req, res) => { try { // Basic checks: server is running; optionally check DB connectivity const health = { ok: true, db: null, bot: null }; try { // if pgClient is available, attempt a simple query if (pgClient && typeof pgClient.query === 'function') { await pgClient.query('SELECT 1'); health.db = true; } } catch (e) { health.db = false; } try { health.bot = (bot && bot.client && bot.client.user) ? true : false; } catch (e) { health.bot = false; } res.json(health); } catch (e) { res.status(500).json({ ok: false }); } }); app.get('/api/servers/:guildId/settings', async (req, res) => { const { guildId } = req.params; try { const settings = await pgClient.getServerSettings(guildId); return res.json(settings || { pingCommand: false }); } catch (err) { console.error('Error fetching settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/settings', async (req, res) => { const { guildId } = req.params; const newSettings = req.body || {}; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; const merged = { ...existing, ...newSettings }; await pgClient.upsertServerSettings(guildId, merged); return res.json({ success: true }); } catch (err) { console.error('Error saving settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Toggle a single command for a guild (preserves other toggles) app.post('/api/servers/:guildId/commands/:cmdName/toggle', async (req, res) => { const { guildId, cmdName } = req.params; const { enabled } = req.body; // boolean const protectedCommands = ['help', 'manage-commands']; if (protectedCommands.includes(cmdName)) { return res.status(403).json({ success: false, message: 'This command is locked and cannot be toggled.' }); } try { if (typeof enabled !== 'boolean') return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' }); const existing = (await pgClient.getServerSettings(guildId)) || {}; if (!existing.commandToggles) existing.commandToggles = {}; existing.commandToggles[cmdName] = enabled; await pgClient.upsertServerSettings(guildId, existing); // notify SSE subscribers about command toggle change try { publishEvent(guildId, 'commandToggle', { cmdName, enabled }); } catch (e) {} try { // if bot is loaded in same process, notify it to update cache if (bot && bot.setGuildSettings) { bot.setGuildSettings(guildId, existing); } } catch (notifyErr) { // ignore if bot isn't accessible } // If a remote bot push URL is configured, notify it with the new settings try { const botPushUrl = process.env.BOT_PUSH_URL || null; const botSecret = process.env.BOT_SECRET || null; if (botPushUrl) { const headers = { 'Content-Type': 'application/json' }; if (botSecret) headers['x-bot-secret'] = botSecret; await axios.post(`${botPushUrl.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers }); } } catch (pushErr) { // ignore push failures } return res.json({ success: true, cmdName, enabled }); } catch (err) { console.error('Error toggling command:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/bot-status', (req, res) => { const { guildId } = req.params; const guild = bot.client.guilds.cache.get(guildId); if (guild) { res.json({ isBotInServer: true }); } else { res.json({ isBotInServer: false }); } }); app.get('/api/client-id', (req, res) => { res.json({ clientId: process.env.DISCORD_CLIENT_ID }); }); app.post('/api/user/theme', async (req, res) => { const { userId, theme } = req.body; try { await pgClient.upsertUserData(userId, { theme }); res.json({ success: true }); } catch (err) { console.error('Error saving user theme:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/leave', async (req, res) => { const { guildId } = req.params; try { const guild = await bot.client.guilds.fetch(guildId); if (guild) { await guild.leave(); // Publish event for bot status change publishEvent('*', 'botStatusUpdate', { guildId, isBotInServer: false }); res.json({ success: true }); } else { res.status(404).json({ success: false, message: 'Bot is not in the specified server' }); } } catch (error) { console.error('Error leaving server:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/channels', async (req, res) => { const { guildId } = req.params; const guild = bot.client.guilds.cache.get(guildId); if (!guild) { return res.json([]); } try { const channels = await guild.channels.fetch(); const textChannels = channels.filter(channel => channel.type === 0).map(channel => ({ id: channel.id, name: channel.name, type: channel.type })); res.json(textChannels); } catch (error) { console.error('Error fetching channels:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/members', async (req, res) => { const { guildId } = req.params; const guild = bot.client.guilds.cache.get(guildId); if (!guild) { return res.json([]); } try { // Get the requesting user from the session/token // For now, we'll assume the frontend sends the user ID in a header or we get it from OAuth // This is a simplified version - in production you'd want proper authentication const members = await guild.members.fetch(); // Filter to members the bot can interact with and format for frontend const bannableMembers = members .filter(member => !member.user.bot) // Exclude bots .map(member => ({ id: member.user.id, username: member.user.username, globalName: member.user.globalName, displayName: member.displayName, avatar: member.user.avatar, joinedAt: member.joinedAt, roles: member.roles.cache.map(role => ({ id: role.id, name: role.name, position: role.position })) })) .sort((a, b) => a.username.localeCompare(b.username)); res.json(bannableMembers); } catch (error) { console.error('Error fetching members:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; const welcomeLeaveSettings = { welcome: { enabled: settings.welcomeEnabled || false, channel: settings.welcomeChannel || '', message: settings.welcomeMessage || 'Welcome to the server, {user}!', customMessage: settings.welcomeCustomMessage || '', }, leave: { enabled: settings.leaveEnabled || false, channel: settings.leaveChannel || '', message: settings.leaveMessage || '{user} has left the server.', customMessage: settings.leaveCustomMessage || '', }, }; res.json(welcomeLeaveSettings); } catch (err) { console.error('Error fetching welcome/leave settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/welcome-leave-settings', async (req, res) => { const { guildId } = req.params; const newSettings = req.body; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; const merged = { ...existing }; merged.welcomeEnabled = newSettings.welcome.enabled; merged.welcomeChannel = newSettings.welcome.channel; merged.welcomeMessage = newSettings.welcome.message; merged.welcomeCustomMessage = newSettings.welcome.customMessage; merged.leaveEnabled = newSettings.leave.enabled; merged.leaveChannel = newSettings.leave.channel; merged.leaveMessage = newSettings.leave.message; merged.leaveCustomMessage = newSettings.leave.customMessage; await pgClient.upsertServerSettings(guildId, merged); return res.json({ success: true }); } catch (err) { console.error('Error saving welcome/leave settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/roles', async (req, res) => { const { guildId } = req.params; const guild = bot.client.guilds.cache.get(guildId); if (!guild) { return res.json([]); } try { const rolesCollection = await guild.roles.fetch(); // Exclude @everyone (role.id === guild.id), exclude managed roles, and only include roles below the bot's highest role const botHighest = guild.members.me.roles.highest.position; const manageable = rolesCollection .filter(role => role.id !== guild.id && !role.managed && role.position < botHighest) .sort((a, b) => b.position - a.position) .map(role => ({ id: role.id, name: role.name, color: role.hexColor })); res.json(manageable); } catch (error) { console.error('Error fetching roles:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/autorole-settings', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; const autoroleSettings = settings.autorole || { enabled: false, roleId: '' }; res.json(autoroleSettings); } catch (err) { console.error('Error fetching autorole settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/autorole-settings', async (req, res) => { const { guildId } = req.params; const { enabled, roleId } = req.body; try { if (pgClient) { const existing = (await pgClient.getServerSettings(guildId)) || {}; existing.autorole = { enabled, roleId }; await pgClient.upsertServerSettings(guildId, existing); return res.json({ success: true }); } const db = readDb(); if (!db[guildId]) db[guildId] = {}; db[guildId].autorole = { enabled, roleId }; writeDb(db); res.json({ success: true }); } catch (err) { console.error('Error saving autorole settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Live notifications (Twitch) - per-guild settings app.get('/api/servers/:guildId/live-notifications', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; const ln = settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '', message: '', customMessage: '' }; return res.json(ln); } catch (err) { console.error('Error fetching live-notifications settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/live-notifications', async (req, res) => { const { guildId } = req.params; const { enabled, twitchUser, channelId, message, customMessage } = req.body || {}; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; const currentLn = existing.liveNotifications || {}; existing.liveNotifications = { enabled: !!enabled, twitchUser: twitchUser || '', channelId: channelId || '', message: message || '', customMessage: customMessage || '', users: currentLn.users || [], // preserve existing users kickUsers: currentLn.kickUsers || [] // preserve existing kick users }; await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser, message: existing.liveNotifications.message, customMessage: existing.liveNotifications.customMessage }); } catch (e) {} return res.json({ success: true }); } catch (err) { console.error('Error saving live-notifications settings:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Twitch users list management for a guild app.get('/api/servers/:guildId/twitch-users', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; const users = (settings.liveNotifications && settings.liveNotifications.users) || []; res.json(users); } catch (err) { console.error('Error fetching twitch users:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/twitch-users', async (req, res) => { const { guildId } = req.params; const { username } = req.body || {}; if (!username) return res.status(400).json({ success: false, message: 'Missing username' }); try { const existing = (await pgClient.getServerSettings(guildId)) || {}; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.users = Array.from(new Set([...(existing.liveNotifications.users || []), username.toLowerCase().trim()])); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} // Optional push to bot process for immediate cache update try { if (process.env.BOT_PUSH_URL) { await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} }).catch(() => {}); } } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error adding twitch user:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.delete('/api/servers/:guildId/twitch-users/:username', async (req, res) => { const { guildId, username } = req.params; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.users = (existing.liveNotifications.users || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase()); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'twitchUsersUpdate', { users: existing.liveNotifications.users || [] }); } catch (e) {} // Optional push to bot process for immediate cache update try { if (process.env.BOT_PUSH_URL) { await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} }).catch(() => {}); } } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error removing twitch user:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // DISABLED: Kick users list management for a guild (temporarily disabled) /* app.get('/api/servers/:guildId/kick-users', async (req, res) => { const { guildId } = req.params; try { const settings = (await pgClient.getServerSettings(guildId)) || {}; const users = (settings.liveNotifications && settings.liveNotifications.kickUsers) || []; res.json(users); } catch (err) { console.error('Error fetching kick users:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/kick-users', async (req, res) => { const { guildId } = req.params; const { username } = req.body || {}; if (!username) return res.status(400).json({ success: false, message: 'Missing username' }); try { const existing = (await pgClient.getServerSettings(guildId)) || {}; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.kickUsers = Array.from(new Set([...(existing.liveNotifications.kickUsers || []), username.toLowerCase().trim()])); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {} // Optional push to bot process for immediate cache update try { if (process.env.BOT_PUSH_URL) { await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} }).catch(() => {}); } } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error adding kick user:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.delete('/api/servers/:guildId/kick-users/:username', async (req, res) => { const { guildId, username } = req.params; try { const existing = (await pgClient.getServerSettings(guildId)) || {}; if (!existing.liveNotifications) existing.liveNotifications = { enabled: false, channelId: '', users: [], kickUsers: [] }; existing.liveNotifications.kickUsers = (existing.liveNotifications.kickUsers || []).filter(u => u.toLowerCase() !== (username || '').toLowerCase()); await pgClient.upsertServerSettings(guildId, existing); try { publishEvent(guildId, 'kickUsersUpdate', { users: existing.liveNotifications.kickUsers || [] }); } catch (e) {} // Optional push to bot process for immediate cache update try { if (process.env.BOT_PUSH_URL) { await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: existing }, { headers: process.env.BOT_SECRET ? { 'x-bot-secret': process.env.BOT_SECRET } : {} }).catch(() => {}); } } catch (_) {} res.json({ success: true }); } catch (err) { console.error('Error removing kick user:', err); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); */ app.get('/', (req, res) => { res.send('Hello from the backend!'); }); // Debug helper: publish an arbitrary SSE event for a guild (guarded by DEBUG_SSE env var) app.post('/api/servers/:guildId/debug/publish', express.json(), (req, res) => { if (!process.env.DEBUG_SSE || process.env.DEBUG_SSE === '0') return res.status(404).json({ success: false, message: 'Not found' }); try { const { guildId } = req.params; const { type, payload } = req.body || {}; if (!type) return res.status(400).json({ success: false, message: 'Missing event type' }); publishEvent(guildId, type, payload || {}); return res.json({ success: true }); } catch (e) { console.error('Debug publish failed:', e); return res.status(500).json({ success: false }); } }); // Return list of bot commands and per-guild enabled/disabled status app.get('/api/servers/:guildId/commands', async (req, res) => { try { const { guildId } = req.params; const guildSettings = (await pgClient.getServerSettings(guildId)) || {}; const toggles = guildSettings.commandToggles || {}; const protectedCommands = ['manage-commands', 'help']; const commands = Array.from(bot.client.commands.values()) .filter(cmd => !cmd.dev) // Filter out dev commands .map(cmd => { const isLocked = protectedCommands.includes(cmd.name); const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false); return { name: cmd.name, description: cmd.description || 'No description.', enabled: isEnabled, locked: isLocked, hasSlashBuilder: !!cmd.builder, }; }); res.json(commands); } catch (error) { console.error('Error returning commands:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // INVITES: create, list, delete app.get('/api/servers/:guildId/invites', async (req, res) => { try { const { guildId } = req.params; const saved = await pgClient.listInvites(guildId); // try to enrich with live data where possible const guild = bot.client.guilds.cache.get(guildId); let liveInvites = []; if (guild) { try { const fetched = await guild.invites.fetch(); liveInvites = Array.from(fetched.values()); } catch (e) { // ignore fetch errors } } const combined = saved.map(inv => { const live = liveInvites.find(li => li.code === inv.code); return { ...inv, uses: live ? live.uses : inv.uses || 0, maxUses: inv.maxUses || (live ? live.maxUses : 0), maxAge: inv.maxAge || (live ? live.maxAge : 0), }; }); res.json(combined); } catch (error) { console.error('Error listing invites:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/invites', async (req, res) => { try { const { guildId } = req.params; const { channelId, maxAge, maxUses, temporary } = req.body || {}; const guild = bot.client.guilds.cache.get(guildId); if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' }); let channel = null; if (channelId) { try { channel = await guild.channels.fetch(channelId); } catch (e) { channel = null; } } if (!channel) { // fall back to first text channel const channels = await guild.channels.fetch(); channel = channels.find(c => c.type === 0) || channels.first(); } if (!channel) return res.status(400).json({ success: false, message: 'No channel available to create invite' }); const inviteOptions = { maxAge: typeof maxAge === 'number' ? maxAge : 0, maxUses: typeof maxUses === 'number' ? maxUses : 0, temporary: !!temporary, unique: true, }; const invite = await channel.createInvite(inviteOptions); // persist invite to Postgres const item = { code: invite.code, url: invite.url, channelId: channel.id, createdAt: new Date().toISOString(), maxUses: invite.maxUses || inviteOptions.maxUses || 0, maxAge: invite.maxAge || inviteOptions.maxAge || 0, temporary: !!invite.temporary, }; await pgClient.addInvite({ code: item.code, guildId, url: item.url, channelId: item.channelId, createdAt: item.createdAt, maxUses: item.maxUses, maxAge: item.maxAge, temporary: item.temporary }); res.json({ success: true, invite: item }); } catch (error) { console.error('Error creating invite:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.delete('/api/servers/:guildId/invites/:code', async (req, res) => { // Require a short-lived invite token issued by /api/servers/:guildId/invite-token const providedToken = req.headers['x-invite-token']; const payload = verifyInviteToken(providedToken); if (!payload || payload.gid !== req.params.guildId) { return res.status(401).json({ success: false, message: 'Unauthorized: missing or invalid invite token' }); } try { const { guildId, code } = req.params; const guild = bot.client.guilds.cache.get(guildId); // Try to delete on Discord if possible if (guild) { try { // fetch invites and delete matching code const fetched = await guild.invites.fetch(); const inv = fetched.find(i => i.code === code); if (inv) await inv.delete(); } catch (e) { // ignore } } if (pgClient) { await pgClient.deleteInvite(guildId, code); } else { const db = readDb(); if (db[guildId] && db[guildId].invites) { db[guildId].invites = db[guildId].invites.filter(i => i.code !== code); writeDb(db); } } res.json({ success: true }); } catch (error) { console.error('Error deleting invite:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // ADMIN LOGS: configuration and retrieval app.get('/api/servers/:guildId/admin-logs-settings', async (req, res) => { try { const { guildId } = req.params; const settings = (await pgClient.getServerSettings(guildId)) || {}; const adminLogsSettings = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } }; res.json(adminLogsSettings); } catch (error) { console.error('Error fetching admin logs settings:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.post('/api/servers/:guildId/admin-logs-settings', async (req, res) => { try { const { guildId } = req.params; const newSettings = req.body || {}; const existing = (await pgClient.getServerSettings(guildId)) || {}; const merged = { ...existing }; merged.adminLogs = { enabled: newSettings.enabled || false, channelId: newSettings.channelId || '', commands: newSettings.commands || { kick: true, ban: true, timeout: true } }; await pgClient.upsertServerSettings(guildId, merged); // Notify bot of settings change if (bot && bot.setGuildSettings) { bot.setGuildSettings(guildId, merged); } // If a remote bot push URL is configured, notify it with the new settings if (process.env.BOT_PUSH_URL) { try { const headers = {}; if (process.env.INTERNAL_API_KEY) { headers['x-api-key'] = process.env.INTERNAL_API_KEY; } await axios.post(`${process.env.BOT_PUSH_URL.replace(/\/$/, '')}/internal/set-settings`, { guildId, settings: merged }, { headers }); } catch (e) { console.error('Failed to push admin logs settings to bot:', e.message); } } res.json({ success: true, settings: merged.adminLogs }); } catch (error) { console.error('Error saving admin logs settings:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.get('/api/servers/:guildId/admin-logs', async (req, res) => { try { const { guildId } = req.params; const { action, limit } = req.query; const limitNum = limit ? parseInt(limit) : 50; let logs; if (action) { logs = await pgClient.getAdminLogsByAction(guildId, action, limitNum); } else { logs = await pgClient.getAdminLogs(guildId, limitNum); } // Transform snake_case to camelCase for frontend compatibility logs = logs.map(log => ({ id: log.id, guildId: log.guild_id, action: log.action, targetUserId: log.target_user_id, targetUsername: log.target_username, moderatorUserId: log.moderator_user_id, moderatorUsername: log.moderator_username, reason: log.reason, duration: log.duration, endDate: log.end_date, timestamp: log.timestamp })); res.json(logs); } catch (error) { console.error('Error fetching admin logs:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.delete('/api/servers/:guildId/admin-logs/:logId', async (req, res) => { try { const { guildId, logId } = req.params; await pgClient.deleteAdminLog(guildId, parseInt(logId)); // Publish SSE event for live updates publishEvent(guildId, 'adminLogDeleted', { logId: parseInt(logId) }); res.json({ success: true }); } catch (error) { console.error('Error deleting admin log:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); app.delete('/api/servers/:guildId/admin-logs', async (req, res) => { try { const { guildId } = req.params; await pgClient.deleteAllAdminLogs(guildId); // Publish SSE event for live updates publishEvent(guildId, 'adminLogsCleared', {}); res.json({ success: true }); } catch (error) { console.error('Error deleting all admin logs:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // Internal endpoint for logging moderation actions app.post('/internal/log-moderation', express.json(), async (req, res) => { try { const { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate } = req.body; if (!guildId || !action || !targetUserId || !moderatorUserId || !reason) { return res.status(400).json({ success: false, message: 'Missing required fields' }); } // Save to database await pgClient.addAdminLog({ guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate }); // Check if logging is enabled for this action and send to Discord channel const settings = (await pgClient.getServerSettings(guildId)) || {}; const adminLogs = settings.adminLogs || { enabled: false, channelId: '', commands: { kick: true, ban: true, timeout: true } }; if (adminLogs.enabled && adminLogs.channelId && adminLogs.commands[action]) { const guild = bot.client.guilds.cache.get(guildId); if (guild) { const channel = guild.channels.cache.get(adminLogs.channelId); if (channel && channel.type === 0) { // GUILD_TEXT const embed = { color: action === 'kick' ? 0xffa500 : action === 'ban' ? 0xff0000 : 0x0000ff, title: `🚨 ${action.charAt(0).toUpperCase() + action.slice(1)} Action`, fields: [ { name: '👤 Target', value: `${targetUsername} (${targetUserId})`, inline: true }, { name: '👮 Moderator', value: `${moderatorUsername} (${moderatorUserId})`, inline: true }, { name: '📝 Reason', value: reason, inline: false } ], timestamp: new Date().toISOString(), footer: { text: 'ECS Admin Logs' } }; if (duration) { embed.fields.push({ name: '⏱️ Duration', value: duration, inline: true }); } if (endDate) { embed.fields.push({ name: '📅 End Date', value: new Date(endDate).toLocaleString(), inline: true }); } try { await channel.send({ embeds: [embed] }); } catch (error) { console.error('Failed to send admin log to Discord:', error); } } } } // Publish SSE event for live updates publishEvent(guildId, 'adminLogAdded', { log: { guildId, action, targetUserId, targetUsername, moderatorUserId, moderatorUsername, reason, duration, endDate, timestamp: new Date().toISOString() } }); res.json({ success: true }); } catch (error) { console.error('Error logging moderation action:', error); res.status(500).json({ success: false, message: 'Internal Server Error' }); } }); // MODERATION: frontend moderation actions app.post('/api/servers/:guildId/moderate', express.json(), async (req, res) => { try { const { guildId } = req.params; const { action, target, reason, duration, moderator } = req.body; if (!action || !target || !reason) { return res.status(400).json({ success: false, message: 'Missing required fields: action, target, reason' }); } // Validate reason has at least 3 words const reasonWords = reason.trim().split(/\s+/); if (reasonWords.length < 3) { return res.status(400).json({ success: false, message: 'Reason must be at least 3 words long' }); } const guild = bot.client.guilds.cache.get(guildId); if (!guild) { return res.status(404).json({ success: false, message: 'Guild not found' }); } // Find the target user let targetUser = null; let targetMember = null; // Try to find by ID first try { targetUser = await bot.client.users.fetch(target); targetMember = guild.members.cache.get(target); } catch (e) { // Try to find by username/mention const members = await guild.members.fetch(); targetMember = members.find(m => m.user.username.toLowerCase().includes(target.toLowerCase()) || m.user.tag.toLowerCase().includes(target.toLowerCase()) || (target.startsWith('<@') && target.includes(m.user.id)) ); if (targetMember) { targetUser = targetMember.user; } } if (!targetUser) { return res.status(404).json({ success: false, message: 'User not found in this server' }); } // Perform the moderation action let result = null; let durationString = null; let endDate = null; switch (action) { case 'kick': if (!targetMember) { return res.status(400).json({ success: false, message: 'User is not in this server' }); } result = await targetMember.kick(reason); break; case 'ban': result = await guild.members.ban(targetUser, { reason }); break; case 'timeout': if (!targetMember) { return res.status(400).json({ success: false, message: 'User is not in this server' }); } if (!duration || duration < 1 || duration > 40320) { return res.status(400).json({ success: false, message: 'Invalid timeout duration (1-40320 minutes)' }); } const timeoutMs = duration * 60 * 1000; endDate = new Date(Date.now() + timeoutMs); result = await targetMember.timeout(timeoutMs, reason); // Format duration string if (duration >= 1440) { durationString = `${Math.floor(duration / 1440)}d ${Math.floor((duration % 1440) / 60)}h ${duration % 60}m`; } else if (duration >= 60) { durationString = `${Math.floor(duration / 60)}h ${duration % 60}m`; } else { durationString = `${duration}m`; } break; default: return res.status(400).json({ success: false, message: 'Invalid action' }); } // Log the moderation action const moderatorUsername = moderator ? (moderator.global_name || moderator.username || 'Unknown User') : 'Web Interface'; try { const logData = { guildId, action, targetUserId: targetUser.id, targetUsername: targetUser.global_name || targetUser.username || 'Unknown User', moderatorUserId: moderator?.id || 'web-interface', moderatorUsername, reason, duration: durationString, endDate }; await fetch(`${BACKEND_BASE}/internal/log-moderation`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(logData) }); } catch (logError) { console.error('Failed to log moderation action:', logError); } res.json({ success: true, message: `${action} action completed successfully` }); } catch (error) { console.error('Error performing moderation action:', error); res.status(500).json({ success: false, message: error.message || 'Internal server error' }); } }); const bot = require('../discord-bot'); bot.login(); // Dev/testing endpoint: force a live announcement app.post('/internal/test-live', express.json(), async (req, res) => { const { guildId, username, title } = req.body || {}; if (!guildId || !username) return res.status(400).json({ success: false, message: 'guildId and username required' }); try { const stream = { user_login: username.toLowerCase(), user_name: username, title: title || `${username} is live (test)`, viewer_count: 1, started_at: new Date().toISOString(), url: `https://www.twitch.tv/${username.toLowerCase()}`, thumbnail_url: null, description: 'Test notification', profile_image_url: null, game_name: 'Testing', }; const result = await bot.announceLive(guildId, stream); res.json(result); } catch (e) { console.error('Error in /internal/test-live:', e && e.message ? e.message : e); res.status(500).json({ success: false, message: 'Internal error' }); } }); app.listen(port, host, () => { console.log(`Server is running on ${host}:${port}`); });