swapped to a new db locally hosted

This commit is contained in:
2025-10-06 00:25:29 -04:00
parent 097583ca0a
commit ca23c0ab8c
40 changed files with 2244 additions and 556 deletions

View File

@@ -1,3 +1,41 @@
# Example backend/.env for ECS-FullStack
# Copy this file to backend/.env and fill values before running the backend
# Postgres connection (required)
# Example formats:
# postgres://user:password@host:5432/dbname
# postgresql://user:password@localhost:5432/dbname
DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/ecs_fullstack
# Discord OAuth / Bot
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_BOT_TOKEN=your_discord_bot_token
# Frontend base URL (where the frontend is served). Used for OAuth redirect and dashboard links.
FRONTEND_BASE=https://discordbot.YOURDOMAIN.com
# Host/port to bind the backend server (bind to 0.0.0.0 or your Tailscale IP as needed)
HOST=0.0.0.0
PORT=3002
# CORS origin - set to your frontend origin for tighter security (or '*' to allow all)
# Example: https://discordbot.YOURDOMAIN.com
CORS_ORIGIN=http://127.0.0.1:3001
# Twitch API (for the watcher and proxy)
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
TWITCH_POLL_INTERVAL_MS=5000
# Optional bot push receiver settings - allows backend to notify a remote bot process
# BOT_PUSH_PORT if you run the bot on another host and want the backend to push settings
BOT_PUSH_PORT=
BOT_PUSH_URL=
BOT_SECRET=
# Optional logging level: debug | info | warn | error
LOG_LEVEL=info
# Example backend .env
# Set the host/interface to bind to (for Tailscale use your 100.x.y.z address)
HOST=0.0.0.0
@@ -10,13 +48,35 @@ FRONTEND_BASE=http://100.x.y.z:3000
# CORS origin (frontend origin) - set to frontend base for tighter security
CORS_ORIGIN=http://100.x.y.z:3000
# Optional invite delete protection
INVITE_API_KEY=replace-with-a-secret
# Postgres connection (replace user, password, host, port, and database name)
# Example for your Tailscale IP 100.111.50.59:
DATABASE_URL=postgres://dbuser:dbpass@100.111.50.59:5432/ecs_db
# Invite token secret (short-lived HMAC tokens for invite delete protection)
INVITE_TOKEN_SECRET=replace-with-a-long-random-secret
# Discord credentials
DISCORD_CLIENT_ID=your_client_id
DISCORD_CLIENT_SECRET=your_client_secret
DISCORD_BOT_TOKEN=your_bot_token
# Encryption key for backend db.json
# Encryption key for backend db.json (only needed if you plan to decrypt/migrate old data)
ENCRYPTION_KEY=pick-a-long-random-string
# --- Twitch API (optional; required for Live Notifications)
# Register an application at https://dev.twitch.tv to obtain these
TWITCH_CLIENT_ID=your_twitch_client_id
TWITCH_CLIENT_SECRET=your_twitch_client_secret
# Poll interval in milliseconds for the bot watcher (default = 30000 = 30s)
TWITCH_POLL_INTERVAL_MS=30000
# --- Bot push (optional) - used when backend and bot run on different hosts
# If the bot runs on a separate host/process, set BOT_PUSH_URL to the public
# URL the bot exposes for receiving settings pushes (backend will POST there)
# Example: BOT_PUSH_URL=http://bot-host:4002
BOT_PUSH_URL=
# Shared secret used to secure backend -> bot pushes. Must match BOT_SECRET in the bot env.
BOT_SECRET=replace-with-a-long-random-secret
# When BOT_PUSH_PORT is set, the bot starts a small HTTP endpoint to accept pushes
# (only needed if bot runs separately and you want immediate pushes).
BOT_PUSH_PORT=4002

View File

@@ -21,6 +21,143 @@ 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' });
}
});
// 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';
@@ -103,8 +240,14 @@ app.get('/auth/discord/callback', async (req, res) => {
const adminGuilds = guildsResponse.data.filter(guild => (guild.permissions & 0x8) === 0x8);
const user = userResponse.data;
const db = readDb();
user.theme = db.users && db.users[user.id] ? db.users[user.id].theme : 'light';
// 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) {
@@ -115,41 +258,117 @@ app.get('/auth/discord/callback', async (req, res) => {
const { readDb, writeDb } = require('./db');
app.get('/api/servers/:guildId/settings', (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || { pingCommand: false };
res.json(settings);
// 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 msg = `event: ${type}\ndata: ${JSON.stringify(payload)}\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);
});
});
app.post('/api/servers/:guildId/settings', (req, res) => {
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 || {};
const db = readDb();
if (!db[guildId]) db[guildId] = {};
// Merge incoming settings with existing settings to avoid overwriting unrelated keys
db[guildId] = { ...db[guildId], ...newSettings };
writeDb(db);
res.json({ success: true });
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', (req, res) => {
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.' });
}
const db = readDb();
if (!db[guildId]) db[guildId] = {};
if (!db[guildId].commandToggles) db[guildId].commandToggles = {};
if (typeof enabled === 'boolean') {
db[guildId].commandToggles[cmdName] = enabled;
writeDb(db);
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' });
}
return res.status(400).json({ success: false, message: 'Missing or invalid "enabled" boolean in request body' });
});
app.get('/api/servers/:guildId/bot-status', (req, res) => {
@@ -166,18 +385,15 @@ app.get('/api/client-id', (req, res) => {
res.json({ clientId: process.env.DISCORD_CLIENT_ID });
});
app.post('/api/user/theme', (req, res) => {
app.post('/api/user/theme', async (req, res) => {
const { userId, theme } = req.body;
const db = readDb();
if (!db.users) {
db.users = {};
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' });
}
if (!db.users[userId]) {
db.users[userId] = {};
}
db.users[userId].theme = theme;
writeDb(db);
res.json({ success: true });
});
app.post('/api/servers/:guildId/leave', async (req, res) => {
@@ -212,51 +428,54 @@ app.get('/api/servers/:guildId/channels', async (req, res) => {
}
});
app.get('/api/servers/:guildId/welcome-leave-settings', (req, res) => {
app.get('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || {};
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 || '',
},
};
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);
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', (req, res) => {
app.post('/api/servers/:guildId/welcome-leave-settings', async (req, res) => {
const { guildId } = req.params;
const newSettings = req.body;
const db = readDb();
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;
if (!db[guildId]) {
db[guildId] = {};
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' });
}
db[guildId].welcomeEnabled = newSettings.welcome.enabled;
db[guildId].welcomeChannel = newSettings.welcome.channel;
db[guildId].welcomeMessage = newSettings.welcome.message;
db[guildId].welcomeCustomMessage = newSettings.welcome.customMessage;
db[guildId].leaveEnabled = newSettings.leave.enabled;
db[guildId].leaveChannel = newSettings.leave.channel;
db[guildId].leaveMessage = newSettings.leave.message;
db[guildId].leaveCustomMessage = newSettings.leave.customMessage;
writeDb(db);
res.json({ success: true });
});
app.get('/api/servers/:guildId/roles', async (req, res) => {
@@ -280,26 +499,113 @@ app.get('/api/servers/:guildId/roles', async (req, res) => {
}
});
app.get('/api/servers/:guildId/autorole-settings', (req, res) => {
app.get('/api/servers/:guildId/autorole-settings', async (req, res) => {
const { guildId } = req.params;
const db = readDb();
const settings = db[guildId] || {};
const autoroleSettings = settings.autorole || { enabled: false, roleId: '' };
res.json(autoroleSettings);
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', (req, res) => {
app.post('/api/servers/:guildId/autorole-settings', async (req, res) => {
const { guildId } = req.params;
const { enabled, roleId } = req.body;
const db = readDb();
if (!db[guildId]) {
db[guildId] = {};
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' });
}
});
db[guildId].autorole = { enabled, roleId };
writeDb(db);
// 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)) || {};
return res.json(settings.liveNotifications || { enabled: false, twitchUser: '', channelId: '' });
} 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 } = req.body || {};
try {
const existing = (await pgClient.getServerSettings(guildId)) || {};
existing.liveNotifications = {
enabled: !!enabled,
twitchUser: twitchUser || '',
channelId: channelId || ''
};
await pgClient.upsertServerSettings(guildId, existing);
try { publishEvent(guildId, 'liveNotificationsUpdate', { enabled: !!enabled, channelId, twitchUser }); } 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: [] };
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) {}
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: [] };
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) {}
res.json({ success: true });
} catch (err) {
console.error('Error removing twitch user:', err);
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});
app.get('/', (req, res) => {
@@ -307,11 +613,10 @@ app.get('/', (req, res) => {
});
// Return list of bot commands and per-guild enabled/disabled status
app.get('/api/servers/:guildId/commands', (req, res) => {
app.get('/api/servers/:guildId/commands', async (req, res) => {
try {
const { guildId } = req.params;
const db = readDb();
const guildSettings = db[guildId] || {};
const guildSettings = (await pgClient.getServerSettings(guildId)) || {};
const toggles = guildSettings.commandToggles || {};
const protectedCommands = ['manage-commands', 'help'];
@@ -338,8 +643,7 @@ app.get('/api/servers/:guildId/commands', (req, res) => {
app.get('/api/servers/:guildId/invites', async (req, res) => {
try {
const { guildId } = req.params;
const db = readDb();
const saved = (db[guildId] && db[guildId].invites) ? db[guildId].invites : [];
const saved = await pgClient.listInvites(guildId);
// try to enrich with live data where possible
const guild = bot.client.guilds.cache.get(guildId);
@@ -377,7 +681,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
const guild = bot.client.guilds.cache.get(guildId);
if (!guild) return res.status(404).json({ success: false, message: 'Guild not found' });
let channel = null;
let channel = null;
if (channelId) {
try { channel = await guild.channels.fetch(channelId); } catch (e) { channel = null; }
}
@@ -397,9 +701,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
const invite = await channel.createInvite(inviteOptions);
const db = readDb();
if (!db[guildId]) db[guildId] = {};
if (!db[guildId].invites) db[guildId].invites = [];
// persist invite to Postgres
const item = {
code: invite.code,
@@ -411,8 +713,7 @@ app.post('/api/servers/:guildId/invites', async (req, res) => {
temporary: !!invite.temporary,
};
db[guildId].invites.push(item);
writeDb(db);
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) {
@@ -430,7 +731,6 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
}
try {
const { guildId, code } = req.params;
const db = readDb();
const guild = bot.client.guilds.cache.get(guildId);
// Try to delete on Discord if possible
@@ -445,9 +745,14 @@ app.delete('/api/servers/:guildId/invites/:code', async (req, res) => {
}
}
if (db[guildId] && db[guildId].invites) {
db[guildId].invites = db[guildId].invites.filter(i => i.code !== code);
writeDb(db);
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 });
@@ -461,6 +766,31 @@ 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}`);
});

View File

@@ -13,7 +13,10 @@
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"node-fetch": "^2.6.7",
"pg": "^8.11.0",
"pg-format": "^1.0.4"
},
"devDependencies": {
"nodemon": "^3.1.3"
@@ -871,6 +874,26 @@
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@@ -983,6 +1006,104 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-format": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -996,6 +1117,45 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1258,6 +1418,15 @@
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -1312,6 +1481,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1358,6 +1533,31 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@@ -17,6 +17,9 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"express": "^4.19.2"
,"pg": "^8.11.0",
"pg-format": "^1.0.4"
,"node-fetch": "^2.6.7"
},
"devDependencies": {
"nodemon": "^3.1.3"

92
backend/pg.js Normal file
View File

@@ -0,0 +1,92 @@
const { Pool } = require('pg');
const format = require('pg-format');
let pool;
function initPool() {
if (pool) return pool;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is not set');
pool = new Pool({ connectionString });
return pool;
}
async function ensureSchema() {
const p = initPool();
// basic tables: servers (settings), invites
await p.query(`
CREATE TABLE IF NOT EXISTS servers (
guild_id TEXT PRIMARY KEY,
settings JSONB DEFAULT '{}'
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
url TEXT,
channel_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
max_uses INTEGER DEFAULT 0,
max_age INTEGER DEFAULT 0,
temporary BOOLEAN DEFAULT false
);
`);
await p.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
discord_id TEXT UNIQUE,
data JSONB DEFAULT '{}'
);
`);
}
// Servers
async function getServerSettings(guildId) {
const p = initPool();
const res = await p.query('SELECT settings FROM servers WHERE guild_id = $1', [guildId]);
if (res.rowCount === 0) return null;
return res.rows[0].settings || {};
}
async function upsertServerSettings(guildId, settings) {
const p = initPool();
await p.query(`INSERT INTO servers(guild_id, settings) VALUES($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET settings = $2`, [guildId, settings]);
}
// Invites
async function listInvites(guildId) {
const p = initPool();
const res = await p.query('SELECT code, url, channel_id, created_at, max_uses, max_age, temporary FROM invites WHERE guild_id = $1 ORDER BY created_at DESC', [guildId]);
return res.rows;
}
async function addInvite(inv) {
const p = initPool();
const q = `INSERT INTO invites(code, guild_id, url, channel_id, created_at, max_uses, max_age, temporary) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (code) DO UPDATE SET url = EXCLUDED.url, channel_id = EXCLUDED.channel_id, max_uses = EXCLUDED.max_uses, max_age = EXCLUDED.max_age, temporary = EXCLUDED.temporary, created_at = EXCLUDED.created_at`;
await p.query(q, [inv.code, inv.guildId, inv.url, inv.channelId, inv.createdAt ? new Date(inv.createdAt) : new Date(), inv.maxUses || 0, inv.maxAge || 0, inv.temporary || false]);
}
async function deleteInvite(guildId, code) {
const p = initPool();
await p.query('DELETE FROM invites WHERE guild_id = $1 AND code = $2', [guildId, code]);
}
// Users
async function getUserData(discordId) {
const p = initPool();
const res = await p.query('SELECT data FROM users WHERE discord_id = $1', [discordId]);
if (res.rowCount === 0) return null;
return res.rows[0].data || {};
}
async function upsertUserData(discordId, data) {
const p = initPool();
await p.query(`INSERT INTO users(discord_id, data) VALUES($1, $2) ON CONFLICT (discord_id) DO UPDATE SET data = $2`, [discordId, data]);
}
module.exports = { initPool, ensureSchema, getServerSettings, upsertServerSettings, listInvites, addInvite, deleteInvite, getUserData, upsertUserData };