1646 lines
62 KiB
JavaScript
1646 lines
62 KiB
JavaScript
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 { code, url, channelId, maxAge, maxUses, temporary, createdAt } = req.body || {};
|
|
|
|
// If code is provided, this is an existing invite to store (from Discord events)
|
|
if (code) {
|
|
const item = {
|
|
code,
|
|
url: url || `https://discord.gg/${code}`,
|
|
channelId: channelId || '',
|
|
createdAt: createdAt || new Date().toISOString(),
|
|
maxUses: maxUses || 0,
|
|
maxAge: maxAge || 0,
|
|
temporary: !!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 });
|
|
return;
|
|
}
|
|
|
|
// Otherwise, create a new invite
|
|
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/storing 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' });
|
|
}
|
|
});
|
|
|
|
// REACTION ROLES: CRUD
|
|
app.get('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
|
try {
|
|
const { guildId } = req.params;
|
|
const rows = await pgClient.listReactionRoles(guildId);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
console.error('Error listing reaction roles:', err);
|
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/servers/:guildId/reaction-roles', async (req, res) => {
|
|
try {
|
|
const { guildId } = req.params;
|
|
const { channelId, name, embed, buttons, messageId } = req.body || {};
|
|
if (!channelId || !name || !embed || !Array.isArray(buttons) || buttons.length === 0) {
|
|
return res.status(400).json({ success: false, message: 'channelId, name, embed, and at least one button are required' });
|
|
}
|
|
const created = await pgClient.createReactionRole({ guildId, channelId, name, embed, buttons, messageId });
|
|
// publish SSE
|
|
publishEvent(guildId, 'reactionRolesUpdate', { action: 'create', reactionRole: created });
|
|
res.json({ success: true, reactionRole: created });
|
|
} catch (err) {
|
|
console.error('Error creating reaction role:', err && err.message ? err.message : err);
|
|
// If the pg helper threw a validation error, return 400 with message
|
|
if (err && err.message && err.message.startsWith('Invalid reaction role payload')) {
|
|
return res.status(400).json({ success: false, message: err.message });
|
|
}
|
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
|
try {
|
|
const { guildId, id } = req.params;
|
|
const updates = req.body || {};
|
|
const existing = await pgClient.getReactionRole(id);
|
|
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
|
const mapped = {
|
|
channel_id: updates.channelId || existing.channel_id,
|
|
message_id: typeof updates.messageId !== 'undefined' ? updates.messageId : existing.message_id,
|
|
name: typeof updates.name !== 'undefined' ? updates.name : existing.name,
|
|
embed: typeof updates.embed !== 'undefined' ? updates.embed : existing.embed,
|
|
buttons: typeof updates.buttons !== 'undefined' ? updates.buttons : existing.buttons
|
|
};
|
|
const updated = await pgClient.updateReactionRole(id, mapped);
|
|
publishEvent(guildId, 'reactionRolesUpdate', { action: 'update', reactionRole: updated });
|
|
res.json({ success: true, reactionRole: updated });
|
|
} catch (err) {
|
|
console.error('Error updating reaction role:', err);
|
|
res.status(500).json({ success: false, message: 'Internal Server Error' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/servers/:guildId/reaction-roles/:id', async (req, res) => {
|
|
try {
|
|
const { guildId, id } = req.params;
|
|
const existing = await pgClient.getReactionRole(id);
|
|
if (!existing || existing.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
|
await pgClient.deleteReactionRole(id);
|
|
publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id });
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Error deleting reaction role:', err);
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Internal: ask bot to publish a reaction role message for a reaction role ID
|
|
app.post('/internal/publish-reaction-role', express.json(), async (req, res) => {
|
|
try {
|
|
// If BOT_SECRET is configured, require the request to include it in the header
|
|
const requiredSecret = process.env.BOT_SECRET;
|
|
if (requiredSecret) {
|
|
const provided = (req.get('x-bot-secret') || req.get('X-Bot-Secret') || '').toString();
|
|
if (!provided || provided !== requiredSecret) {
|
|
console.warn('/internal/publish-reaction-role: missing or invalid x-bot-secret header');
|
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
}
|
|
}
|
|
|
|
const { guildId, id } = req.body || {};
|
|
if (!guildId || !id) return res.status(400).json({ success: false, message: 'guildId and id required' });
|
|
const rr = await pgClient.getReactionRole(id);
|
|
if (!rr || rr.guild_id !== guildId) return res.status(404).json({ success: false, message: 'Not found' });
|
|
const result = await bot.postReactionRoleMessage(guildId, rr);
|
|
if (result && result.success) {
|
|
// update db already attempted by bot; publish SSE update
|
|
publishEvent(guildId, 'reactionRolesUpdate', { action: 'posted', id, messageId: result.messageId });
|
|
} else {
|
|
// If the channel or message cannot be created because it no longer exists, remove the DB entry
|
|
if (result && result.message && result.message.toLowerCase && (result.message.includes('Channel not found') || result.message.includes('Guild not found'))) {
|
|
try { await pgClient.deleteReactionRole(id); publishEvent(guildId, 'reactionRolesUpdate', { action: 'delete', id }); } catch(e){}
|
|
}
|
|
}
|
|
res.json(result);
|
|
} catch (e) {
|
|
console.error('Error in /internal/publish-reaction-role:', e);
|
|
res.status(500).json({ success: false, message: 'Internal error' });
|
|
}
|
|
});
|
|
|
|
app.listen(port, host, () => {
|
|
console.log(`Server is running on ${host}:${port}`);
|
|
});
|