2026-04-21 13:37:39 +01:00
|
|
|
const express = require('express');
|
|
|
|
|
const cors = require('cors');
|
2026-05-16 15:10:27 +01:00
|
|
|
const cookieParser = require('cookie-parser');
|
2026-04-21 13:37:39 +01:00
|
|
|
const fs = require('fs');
|
|
|
|
|
const os = require('os');
|
2026-05-16 15:10:27 +01:00
|
|
|
const path = require('path');
|
2026-04-21 13:37:39 +01:00
|
|
|
const yaml = require('js-yaml');
|
|
|
|
|
const YAML = require('yaml');
|
|
|
|
|
const db = require('../Structures/Database.js');
|
2026-05-16 15:10:27 +01:00
|
|
|
const configStore = require('../Structures/ConfigStore.js');
|
|
|
|
|
const { reloadBot, restartDiscordBot } = require('../Structures/BotReload.js');
|
|
|
|
|
const { LOG_TYPES, fetchLogs, getLogMeta } = require('../Structures/LogReader.js');
|
|
|
|
|
const { evaluateAllModules } = require('../Structures/ModuleStatus.js');
|
|
|
|
|
const { getBotInviteUrl } = require('../Structures/BotInvite.js');
|
|
|
|
|
const notificationStore = require('../Structures/NotificationStore.js');
|
|
|
|
|
const {
|
|
|
|
|
notifyBotRestart,
|
|
|
|
|
notifyUpdateInstalled,
|
|
|
|
|
runUpdateCheck,
|
|
|
|
|
} = require('../Structures/NotificationService.js');
|
|
|
|
|
const {
|
|
|
|
|
SESSION_COOKIE,
|
|
|
|
|
signSession,
|
|
|
|
|
verifySession,
|
|
|
|
|
createSessionPayload,
|
|
|
|
|
sessionCookieOptions,
|
|
|
|
|
} = require('../Structures/DashboardSession.js');
|
|
|
|
|
const {
|
|
|
|
|
OAUTH_STATE_COOKIE,
|
|
|
|
|
buildAuthorizeUrl,
|
|
|
|
|
exchangeCode,
|
|
|
|
|
fetchDiscordUser,
|
|
|
|
|
createOAuthState,
|
|
|
|
|
oauthStateCookieOptions,
|
|
|
|
|
isOAuthConfigured,
|
|
|
|
|
discordAvatarUrl,
|
|
|
|
|
} = require('../Structures/DiscordOAuth.js');
|
|
|
|
|
const {
|
|
|
|
|
bootstrapFromOAuth,
|
|
|
|
|
getAccess,
|
|
|
|
|
canLogin,
|
|
|
|
|
formatPublicUser,
|
|
|
|
|
syncDiscordProfile,
|
|
|
|
|
listUsers,
|
|
|
|
|
addUser,
|
|
|
|
|
updateUser,
|
|
|
|
|
removeUser,
|
|
|
|
|
hasPermission,
|
|
|
|
|
canAccessConfig,
|
|
|
|
|
ROLES,
|
|
|
|
|
CONFIG_FILES,
|
|
|
|
|
} = require('../Structures/DashboardUserStore.js');
|
|
|
|
|
const {
|
|
|
|
|
grantServiceAccess,
|
|
|
|
|
forbid,
|
|
|
|
|
assertConfigFilesEditable,
|
|
|
|
|
fullPermissions,
|
|
|
|
|
} = require('../Structures/DashboardAuth.js');
|
|
|
|
|
const {
|
|
|
|
|
syncGuildHealth,
|
|
|
|
|
leaveGuild,
|
|
|
|
|
fetchGuildResources,
|
|
|
|
|
clearGuildResourcesCache,
|
|
|
|
|
} = require('../Structures/GuildManager.js');
|
|
|
|
|
const { withTimeout } = require('../Structures/asyncTimeout.js');
|
|
|
|
|
const {
|
|
|
|
|
listCatalogAddons,
|
|
|
|
|
listInstalledAddons,
|
|
|
|
|
installAddonFromCatalog,
|
|
|
|
|
listLocalAddonConfigs,
|
|
|
|
|
readAddonConfig,
|
|
|
|
|
writeAddonConfig,
|
|
|
|
|
} = require('../Structures/AddonCatalog.js');
|
|
|
|
|
|
|
|
|
|
const DASHBOARD_DIR = path.join(__dirname, '../public/dashboard');
|
|
|
|
|
const UPDATE_REPO = 'C-h-a-r/SupportBot-Dashboard';
|
|
|
|
|
const UPDATE_BRANCH = 'release';
|
|
|
|
|
const UPDATE_ZIP_URL = `https://github.com/${UPDATE_REPO}/archive/refs/heads/${UPDATE_BRANCH}.zip`;
|
2026-04-21 13:37:39 +01:00
|
|
|
|
|
|
|
|
class APIServer {
|
|
|
|
|
constructor(client) {
|
|
|
|
|
this.client = client;
|
|
|
|
|
this.app = express();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const apiCfg = yaml.load(fs.readFileSync('./Configs/api.yml', 'utf8')).API;
|
|
|
|
|
this.config = apiCfg;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('[API] Failed to load api.yml, defaulting to disabled.');
|
|
|
|
|
this.config = { Enabled: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.config.Enabled) return;
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
this.oauth = this.config.OAuth || {};
|
|
|
|
|
bootstrapFromOAuth(this.oauth);
|
|
|
|
|
|
|
|
|
|
this.app.use(cors({ origin: true, credentials: true }));
|
|
|
|
|
this.app.use(cookieParser());
|
2026-04-21 13:37:39 +01:00
|
|
|
this.app.use(express.json());
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
this.app.get('/api/health', (req, res) => {
|
|
|
|
|
const guild =
|
|
|
|
|
this.client?.user ? syncGuildHealth(this.client) : null;
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
dashboard: fs.existsSync(DASHBOARD_DIR),
|
|
|
|
|
botReady: Boolean(this.client?.user) && !this.client?.__restarting,
|
|
|
|
|
botRestarting: Boolean(this.client?.__restarting),
|
|
|
|
|
oauthEnabled: isOAuthConfigured(this.oauth),
|
|
|
|
|
guild,
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
this.setupAuthRoutes();
|
|
|
|
|
|
|
|
|
|
const api = express.Router();
|
|
|
|
|
api.use((req, res, next) => this.authenticate(req, res, next));
|
|
|
|
|
this.apiRouter = api;
|
2026-04-21 13:37:39 +01:00
|
|
|
this.setupRoutes();
|
2026-05-16 15:10:27 +01:00
|
|
|
this.app.use('/api', api);
|
|
|
|
|
|
|
|
|
|
this.setupDashboard();
|
|
|
|
|
|
|
|
|
|
this.app.use((err, req, res, next) => {
|
|
|
|
|
if (res.headersSent) return next(err);
|
|
|
|
|
console.error('[API] Unhandled error:', err);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: err.message || 'Internal server error',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authenticate(req, res, next) {
|
|
|
|
|
const secret = this.config.SecretKey;
|
|
|
|
|
const session = verifySession(req.cookies?.[SESSION_COOKIE], secret);
|
|
|
|
|
|
|
|
|
|
if (session?.userId) {
|
|
|
|
|
const access = getAccess(session.userId, this.oauth);
|
|
|
|
|
if (access) {
|
|
|
|
|
req.dashboardUser = session;
|
|
|
|
|
req.dashboardPermissions = access.permissions;
|
|
|
|
|
req.dashboardRole = access.role;
|
|
|
|
|
req.dashboardIsOwner = access.isOwner;
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const authHeader = req.headers.authorization;
|
|
|
|
|
if (authHeader === `Bearer ${secret}`) {
|
|
|
|
|
grantServiceAccess(req);
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.status(401).json({
|
|
|
|
|
error: 'Unauthorized. Sign in with Discord.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requirePermission(permission) {
|
|
|
|
|
return (req, res, next) => {
|
|
|
|
|
if (req.dashboardService) return next();
|
|
|
|
|
if (hasPermission(req.dashboardPermissions, permission)) return next();
|
|
|
|
|
return forbid(res);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requireConfigAccess(mode = 'view') {
|
|
|
|
|
return (req, res, next) => {
|
|
|
|
|
if (req.dashboardService) return next();
|
|
|
|
|
const file = req.params?.file || req.body?.filename;
|
|
|
|
|
if (!file) return next();
|
|
|
|
|
if (!canAccessConfig(req.dashboardPermissions, file, mode)) {
|
|
|
|
|
return forbid(res);
|
|
|
|
|
}
|
|
|
|
|
return next();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
respondAuthUser(req, res, session) {
|
|
|
|
|
const access = getAccess(session.userId, this.oauth);
|
|
|
|
|
if (!access) {
|
|
|
|
|
return res.status(401).json({ success: false, error: 'Not signed in' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: formatPublicUser(
|
|
|
|
|
access.entry,
|
|
|
|
|
access,
|
|
|
|
|
{
|
|
|
|
|
id: session.userId,
|
|
|
|
|
username: session.username,
|
|
|
|
|
global_name: session.globalName,
|
|
|
|
|
avatar: session.avatar,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupAuthRoutes() {
|
|
|
|
|
const secret = this.config.SecretKey;
|
|
|
|
|
const oauth = this.oauth;
|
|
|
|
|
|
|
|
|
|
this.app.get('/api/auth/discord', (req, res) => {
|
|
|
|
|
if (!isOAuthConfigured(oauth)) {
|
|
|
|
|
return res.redirect('/login?error=oauth_not_configured');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state = createOAuthState();
|
|
|
|
|
res.cookie(OAUTH_STATE_COOKIE, state, oauthStateCookieOptions());
|
|
|
|
|
res.redirect(buildAuthorizeUrl(oauth, state));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.app.get('/api/auth/discord/callback', async (req, res) => {
|
|
|
|
|
if (!isOAuthConfigured(oauth)) {
|
|
|
|
|
return res.redirect('/login?error=oauth_not_configured');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { code, state, error } = req.query;
|
|
|
|
|
if (error) {
|
|
|
|
|
return res.redirect(`/login?error=${encodeURIComponent(String(error))}`);
|
|
|
|
|
}
|
|
|
|
|
if (!code || !state) {
|
|
|
|
|
return res.redirect('/login?error=missing_code');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const savedState = req.cookies?.[OAUTH_STATE_COOKIE];
|
|
|
|
|
res.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
|
|
|
|
|
|
|
|
|
|
if (!savedState || savedState !== state) {
|
|
|
|
|
return res.redirect('/login?error=invalid_state');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const accessToken = await exchangeCode(oauth, String(code));
|
|
|
|
|
const user = await fetchDiscordUser(accessToken);
|
|
|
|
|
|
|
|
|
|
if (!canLogin(user.id, oauth)) {
|
|
|
|
|
return res.redirect('/login?error=not_allowed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
syncDiscordProfile(user.id, {
|
|
|
|
|
username: user.username,
|
|
|
|
|
globalName: user.global_name || null,
|
|
|
|
|
avatar: user.avatar || null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = createSessionPayload(user);
|
|
|
|
|
const token = signSession(payload, secret);
|
|
|
|
|
res.cookie(SESSION_COOKIE, token, sessionCookieOptions());
|
|
|
|
|
res.redirect('/');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Discord OAuth callback failed:', err.message);
|
|
|
|
|
res.redirect('/login?error=oauth_failed');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.app.get('/api/auth/me', (req, res) => {
|
|
|
|
|
const session = verifySession(req.cookies?.[SESSION_COOKIE], secret);
|
|
|
|
|
if (!session?.userId) {
|
|
|
|
|
return res.status(401).json({ success: false, error: 'Not signed in' });
|
|
|
|
|
}
|
|
|
|
|
return this.respondAuthUser(req, res, session);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.app.post('/api/auth/logout', (req, res) => {
|
|
|
|
|
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 16:44:04 +01:00
|
|
|
resolveDashboardDir() {
|
|
|
|
|
const candidates = [
|
|
|
|
|
DASHBOARD_DIR,
|
|
|
|
|
path.join(process.cwd(), 'public', 'dashboard'),
|
|
|
|
|
];
|
|
|
|
|
for (const dir of candidates) {
|
|
|
|
|
if (fs.existsSync(path.join(dir, 'index.html'))) {
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
setupDashboard() {
|
2026-05-16 16:44:04 +01:00
|
|
|
const dashboardDir = this.resolveDashboardDir();
|
|
|
|
|
const port = this.config.Port || 3000;
|
|
|
|
|
|
|
|
|
|
if (!dashboardDir) {
|
|
|
|
|
console.warn(
|
|
|
|
|
'[API] Dashboard UI missing — add the public/dashboard/ folder from the repository (index.html + assets/).',
|
|
|
|
|
);
|
|
|
|
|
this.app.get('/', (req, res) => {
|
|
|
|
|
res.status(503).type('html').send(`<!DOCTYPE html>
|
|
|
|
|
<html lang="en"><head><meta charset="utf-8"><title>SupportBot Dashboard</title>
|
|
|
|
|
<style>body{font-family:system-ui,sans-serif;max-width:36rem;margin:3rem auto;padding:0 1rem;color:#e8e8f0;background:#0c0c10}
|
|
|
|
|
h1{font-size:1.25rem}a{color:#a78bfa}</style></head><body>
|
|
|
|
|
<h1>Dashboard files not found</h1>
|
|
|
|
|
<p>The API is running, but <code>public/dashboard/</code> is missing from this install (no <code>index.html</code>).</p>
|
|
|
|
|
<p>Clone or download the repo including <strong>public/dashboard/</strong>, then restart the bot.</p>
|
|
|
|
|
<p><a href="/api/health">Check API health</a></p>
|
|
|
|
|
</body></html>`);
|
|
|
|
|
});
|
2026-05-16 15:10:27 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 16:44:04 +01:00
|
|
|
console.log(`[API] Serving dashboard from ${dashboardDir}`);
|
|
|
|
|
|
|
|
|
|
this.app.use(
|
|
|
|
|
express.static(dashboardDir, {
|
|
|
|
|
index: 'index.html',
|
|
|
|
|
fallthrough: true,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-16 15:10:27 +01:00
|
|
|
|
2026-05-16 16:44:04 +01:00
|
|
|
this.app.use((req, res, next) => {
|
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
|
|
|
if (req.path.startsWith('/api')) return next();
|
|
|
|
|
res.sendFile(path.join(dashboardDir, 'index.html'), (err) => {
|
2026-05-16 15:10:27 +01:00
|
|
|
if (err) next(err);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 16:44:04 +01:00
|
|
|
console.log('[API] Dashboard available at http://localhost:' + port);
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start(port) {
|
2026-05-16 15:10:27 +01:00
|
|
|
if (this._httpServer) {
|
|
|
|
|
return this._httpServer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 13:37:39 +01:00
|
|
|
const listenPort = port || this.config.Port || 3000;
|
2026-05-16 15:10:27 +01:00
|
|
|
this._httpServer = this.app.listen(listenPort, () => {
|
2026-04-21 13:37:39 +01:00
|
|
|
console.log(`[API] Server running on port ${listenPort}`);
|
2026-05-16 15:10:27 +01:00
|
|
|
this.startNotificationJobs();
|
2026-04-21 13:37:39 +01:00
|
|
|
});
|
2026-05-16 15:10:27 +01:00
|
|
|
|
|
|
|
|
this._httpServer.on('error', (err) => {
|
|
|
|
|
if (err.code === 'EADDRINUSE') {
|
|
|
|
|
console.error(
|
|
|
|
|
`[API] Port ${listenPort} is already in use. Change Port in Configs/api.yml.`,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
console.error('[API] Server error:', err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this._httpServer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async restartBotAfterConfigSave() {
|
|
|
|
|
if (!this.client) {
|
|
|
|
|
return { success: false, error: 'Bot client not available' };
|
|
|
|
|
}
|
|
|
|
|
if (this.client.__restarting) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot restart already in progress. Try again in a moment.',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await withTimeout(
|
|
|
|
|
restartDiscordBot(this.client),
|
|
|
|
|
90_000,
|
|
|
|
|
'Bot restart timed out after 90 seconds',
|
|
|
|
|
);
|
|
|
|
|
clearGuildResourcesCache();
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
commands: result.commands?.count ?? 0,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Bot restart failed:', err);
|
|
|
|
|
return { success: false, error: err.message };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startNotificationJobs() {
|
|
|
|
|
const FIRST_MS = 60 * 1000;
|
|
|
|
|
const INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await runUpdateCheck();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('[Notifications] Update check failed:', err.message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this._notificationTimers = [
|
|
|
|
|
setTimeout(run, FIRST_MS),
|
|
|
|
|
setInterval(run, INTERVAL_MS),
|
|
|
|
|
];
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupRoutes() {
|
2026-05-16 15:10:27 +01:00
|
|
|
const app = this.apiRouter;
|
|
|
|
|
const oauth = this.oauth;
|
|
|
|
|
const p = (permission) => this.requirePermission(permission);
|
|
|
|
|
const cv = (mode) => this.requireConfigAccess(mode);
|
|
|
|
|
|
|
|
|
|
app.get('/dashboard-users', p('users.view'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
users: listUsers(oauth),
|
|
|
|
|
roles: ROLES,
|
|
|
|
|
configFiles: CONFIG_FILES,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/dashboard-users', p('users.manage'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const actorId = req.dashboardUser?.userId || 'service';
|
|
|
|
|
const user = addUser(req.body, actorId, oauth);
|
|
|
|
|
res.json({ success: true, data: user });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(400).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.patch('/dashboard-users/:id', p('users.manage'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const actorId = req.dashboardUser?.userId || 'service';
|
|
|
|
|
const user = updateUser(req.params.id, req.body, actorId, oauth);
|
|
|
|
|
res.json({ success: true, data: user });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(400).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/dashboard-users/:id', p('users.manage'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
removeUser(req.params.id, oauth);
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(400).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/guild/status', p('overview'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.client?.user) {
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
status: 'offline',
|
|
|
|
|
configuredGuildId: null,
|
|
|
|
|
message: 'Bot is not connected to Discord.',
|
|
|
|
|
configuredGuild: null,
|
|
|
|
|
extraGuilds: [],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: syncGuildHealth(this.client),
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/guild/resources', (req, res, next) => {
|
|
|
|
|
if (req.dashboardService) return next();
|
|
|
|
|
const canAny = CONFIG_FILES.some((f) =>
|
|
|
|
|
canAccessConfig(req.dashboardPermissions, f, 'view'),
|
|
|
|
|
);
|
|
|
|
|
if (!canAny && !hasPermission(req.dashboardPermissions, 'overview')) {
|
|
|
|
|
return forbid(res);
|
|
|
|
|
}
|
|
|
|
|
return next();
|
|
|
|
|
}, async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
if (this.client?.__restarting) {
|
|
|
|
|
return res.status(503).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot is restarting. Channel and role lists will be back shortly.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!this.client?.user) {
|
|
|
|
|
return res.status(503).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot is not connected.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const data = await fetchGuildResources(this.client);
|
|
|
|
|
if (!data) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot is not in the configured server. Set General.GuildId and invite the bot.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
res.json({ success: true, data });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/guild/:guildId/leave', p('settings.update'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.client?.user) {
|
|
|
|
|
return res.status(503).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot is not connected.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const health = await leaveGuild(this.client, req.params.guildId);
|
|
|
|
|
res.json({ success: true, data: health });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(400).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/stats', p('overview'), async (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
try {
|
2026-05-16 15:10:27 +01:00
|
|
|
if (this.client.__restarting) {
|
|
|
|
|
return res.json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'Bot is restarting after a config change. Try again shortly.',
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-21 13:37:39 +01:00
|
|
|
if (!this.client.user) {
|
|
|
|
|
return res.json({ success: false, error: 'Bot is still starting up.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let userCount = 0;
|
|
|
|
|
this.client.guilds.cache.forEach(guild => {
|
|
|
|
|
userCount += guild.memberCount;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const allTickets = db.getAllTickets() || [];
|
2026-05-16 15:10:27 +01:00
|
|
|
const openTickets = allTickets.filter(t => t.status === 'open').length;
|
|
|
|
|
const closedTickets = allTickets.filter(t => t.status !== 'open').length;
|
|
|
|
|
|
|
|
|
|
const transcriptDir = path.join(__dirname, '../Data/Transcripts');
|
|
|
|
|
let transcriptCount = 0;
|
|
|
|
|
if (fs.existsSync(transcriptDir)) {
|
|
|
|
|
transcriptCount = fs
|
|
|
|
|
.readdirSync(transcriptDir)
|
|
|
|
|
.filter((f) => f.endsWith('-transcript.html')).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modules = evaluateAllModules(configStore, {
|
|
|
|
|
transcriptCount,
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
|
|
|
|
|
const totalMem = os.totalmem();
|
|
|
|
|
const freeMem = os.freemem();
|
|
|
|
|
const usedMem = totalMem - freeMem;
|
|
|
|
|
const ramPercent = Math.round((usedMem / totalMem) * 100);
|
|
|
|
|
const cpuLoad = os.loadavg()[0].toFixed(2);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
bot: {
|
|
|
|
|
username: this.client.user.username,
|
|
|
|
|
id: this.client.user.id,
|
|
|
|
|
avatar: this.client.user.displayAvatarURL(),
|
2026-05-16 15:10:27 +01:00
|
|
|
inviteUrl: getBotInviteUrl(this.client.user.id),
|
2026-04-21 13:37:39 +01:00
|
|
|
version: require('../package.json').version,
|
|
|
|
|
ping: this.client.ws.ping,
|
|
|
|
|
uptime: process.uptime(),
|
|
|
|
|
},
|
|
|
|
|
servers: this.client.guilds.cache.size,
|
|
|
|
|
users: userCount,
|
|
|
|
|
tickets: {
|
|
|
|
|
total: allTickets.length,
|
|
|
|
|
open: openTickets,
|
|
|
|
|
closed: closedTickets
|
|
|
|
|
},
|
2026-05-16 15:10:27 +01:00
|
|
|
modules,
|
2026-04-21 13:37:39 +01:00
|
|
|
hosting: {
|
|
|
|
|
ram_used: (usedMem / 1024 / 1024 / 1024).toFixed(2) + ' GB',
|
|
|
|
|
ram_total: (totalMem / 1024 / 1024 / 1024).toFixed(2) + ' GB',
|
|
|
|
|
ram_percent: ramPercent,
|
|
|
|
|
cpu_load: cpuLoad,
|
|
|
|
|
uptime: Math.floor(os.uptime() / 86400) + ' Days'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Error fetching stats:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: 'Internal Server Error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/configs/:file', cv('view'), (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai'];
|
|
|
|
|
const file = req.params.file;
|
|
|
|
|
if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' });
|
|
|
|
|
try {
|
|
|
|
|
const configData = fs.readFileSync(`./Configs/${file}.yml`, 'utf8');
|
|
|
|
|
res.json({ success: true, data: configData });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: 'Failed to read config' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.put('/configs/raw', cv('edit'), async (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai'];
|
|
|
|
|
const file = req.body.filename;
|
2026-05-16 15:10:27 +01:00
|
|
|
let content = req.body.content;
|
2026-04-21 13:37:39 +01:00
|
|
|
if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' });
|
|
|
|
|
try {
|
2026-05-16 15:10:27 +01:00
|
|
|
if (file === 'supportbot' && /^\s*Token:\s*["']BOT_TOKEN["']/m.test(content)) {
|
|
|
|
|
const currentPath = `./Configs/${file}.yml`;
|
|
|
|
|
if (fs.existsSync(currentPath)) {
|
|
|
|
|
const current = fs.readFileSync(currentPath, 'utf8');
|
|
|
|
|
const tokenMatch = current.match(/^(\s*Token:\s*)["']([^"']+)["']/m);
|
|
|
|
|
if (tokenMatch && tokenMatch[2] !== 'BOT_TOKEN') {
|
|
|
|
|
content = content.replace(
|
|
|
|
|
/^(\s*Token:\s*)["']BOT_TOKEN["']/m,
|
|
|
|
|
`$1"${tokenMatch[2]}"`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-21 13:37:39 +01:00
|
|
|
fs.writeFileSync(`./Configs/${file}.yml`, content);
|
2026-05-16 15:10:27 +01:00
|
|
|
configStore.reloadFile(file);
|
|
|
|
|
|
|
|
|
|
let botRestart = { success: false, error: 'Bot client not available' };
|
|
|
|
|
if (this.client) {
|
|
|
|
|
botRestart = await this.restartBotAfterConfigSave();
|
|
|
|
|
notifyBotRestart(botRestart.success, botRestart.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
botRestart,
|
|
|
|
|
message: botRestart.success
|
|
|
|
|
? 'Config saved and bot restarted. API and dashboard stayed online.'
|
|
|
|
|
: 'Config saved but the bot could not restart. Check server logs.',
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: 'Failed to write config' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/configs/json/:file', cv('view'), (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai'];
|
|
|
|
|
const file = req.params.file;
|
|
|
|
|
if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' });
|
|
|
|
|
try {
|
|
|
|
|
const configData = yaml.load(fs.readFileSync(`./Configs/${file}.yml`, 'utf8'));
|
|
|
|
|
res.json({ success: true, data: configData });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: 'Failed to parse config' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.post('/configs/update-fields/multi', async (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const updates = req.body;
|
|
|
|
|
if (!updates || typeof updates !== 'object') return res.status(400).json({ success: false, error: 'Invalid updates' });
|
2026-05-16 15:10:27 +01:00
|
|
|
|
|
|
|
|
const touchedFiles = new Set();
|
|
|
|
|
for (const keyPath of Object.keys(updates)) {
|
|
|
|
|
touchedFiles.add(keyPath.split(':')[0]);
|
|
|
|
|
}
|
|
|
|
|
if (!req.dashboardService) {
|
|
|
|
|
const denied = assertConfigFilesEditable(
|
|
|
|
|
req.dashboardPermissions,
|
|
|
|
|
[...touchedFiles],
|
|
|
|
|
res,
|
|
|
|
|
);
|
|
|
|
|
if (denied) return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 13:37:39 +01:00
|
|
|
try {
|
2026-05-16 15:10:27 +01:00
|
|
|
const touched = new Set();
|
2026-04-21 13:37:39 +01:00
|
|
|
for (const [keyPath, value] of Object.entries(updates)) {
|
2026-05-16 15:10:27 +01:00
|
|
|
const [fileName, fieldPath] = keyPath.split(':');
|
2026-04-21 13:37:39 +01:00
|
|
|
const fileContent = fs.readFileSync(`./Configs/${fileName}.yml`, 'utf8');
|
|
|
|
|
const doc = YAML.parseDocument(fileContent);
|
2026-05-16 15:10:27 +01:00
|
|
|
const keys = fieldPath.split('.');
|
2026-04-21 13:37:39 +01:00
|
|
|
doc.setIn(keys, value);
|
|
|
|
|
fs.writeFileSync(`./Configs/${fileName}.yml`, doc.toString());
|
2026-05-16 15:10:27 +01:00
|
|
|
touched.add(fileName);
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
2026-05-16 15:10:27 +01:00
|
|
|
|
|
|
|
|
const reloadFiles = [...touched].filter((f) => f !== 'api');
|
|
|
|
|
|
|
|
|
|
let botRestart = { success: false, error: 'Bot client not available' };
|
|
|
|
|
if (reloadFiles.length && this.client) {
|
|
|
|
|
botRestart = await this.restartBotAfterConfigSave();
|
|
|
|
|
notifyBotRestart(botRestart.success, botRestart.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
reloaded: reloadFiles,
|
|
|
|
|
botRestart,
|
|
|
|
|
message: botRestart.success
|
|
|
|
|
? 'Config saved and bot restarted. API and dashboard stayed online.'
|
|
|
|
|
: 'Config saved but the bot could not restart. Check server logs.',
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Update failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/notifications', p('overview'), (req, res) => {
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
items: notificationStore.list(),
|
|
|
|
|
unreadCount: notificationStore.unreadCount(),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/notifications/read', p('overview'), (req, res) => {
|
|
|
|
|
const { ids } = req.body || {};
|
|
|
|
|
if (ids === 'all') {
|
|
|
|
|
notificationStore.markRead('all');
|
|
|
|
|
} else if (Array.isArray(ids)) {
|
|
|
|
|
notificationStore.markRead(ids);
|
|
|
|
|
} else if (typeof ids === 'string') {
|
|
|
|
|
notificationStore.markRead([ids]);
|
|
|
|
|
}
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: { unreadCount: notificationStore.unreadCount() },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/notifications/:id', p('overview'), (req, res) => {
|
|
|
|
|
notificationStore.dismiss(req.params.id);
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: { unreadCount: notificationStore.unreadCount() },
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.post('/system/reload-configs', p('settings.update'), async (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
try {
|
2026-05-16 15:10:27 +01:00
|
|
|
const file = req.body?.file;
|
|
|
|
|
const valid = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai'];
|
|
|
|
|
if (file && !valid.includes(file)) {
|
|
|
|
|
return res.status(400).json({ success: false, error: 'Invalid config file' });
|
|
|
|
|
}
|
|
|
|
|
const result = await reloadBot(this.client, {
|
|
|
|
|
fileKey: file || null,
|
|
|
|
|
refreshCommands: !file || file === 'commands',
|
2026-04-21 13:37:39 +01:00
|
|
|
});
|
2026-05-16 15:10:27 +01:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
reloaded: result.reloaded,
|
|
|
|
|
commands: result.commands,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Reload failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/system/logs/meta', p('logs'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json({ success: true, data: { types: LOG_TYPES, files: getLogMeta() } });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Log meta failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/system/logs', p('logs'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const typesParam = req.query.types;
|
|
|
|
|
const types = typesParam
|
|
|
|
|
? String(typesParam).split(',').map((t) => t.trim())
|
|
|
|
|
: LOG_TYPES;
|
2026-04-21 13:37:39 +01:00
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
let cursor = {};
|
|
|
|
|
if (req.query.cursor) {
|
|
|
|
|
try {
|
|
|
|
|
cursor = JSON.parse(String(req.query.cursor));
|
|
|
|
|
} catch {
|
|
|
|
|
return res.status(400).json({ success: false, error: 'Invalid cursor' });
|
|
|
|
|
}
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
const hasCursor = Object.keys(cursor).length > 0;
|
|
|
|
|
const tailParam = req.query.tail;
|
|
|
|
|
const initialLines = hasCursor
|
|
|
|
|
? 0
|
|
|
|
|
: Math.min(
|
|
|
|
|
1000,
|
|
|
|
|
Math.max(0, parseInt(tailParam, 10) || 400),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { entries, cursor: nextCursor } = fetchLogs(types, cursor, initialLines);
|
2026-04-21 13:37:39 +01:00
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
entries,
|
|
|
|
|
cursor: nextCursor,
|
|
|
|
|
},
|
2026-04-21 13:37:39 +01:00
|
|
|
});
|
2026-05-16 15:10:27 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Logs fetch failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/system/update-check', p('settings.view'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const info = await runUpdateCheck();
|
|
|
|
|
res.json({ success: true, data: info });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Update check failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: 'Could not check for updates. Try again later.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/addons/catalog', p('settings.view'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const catalog = await listCatalogAddons();
|
|
|
|
|
const installed = await listInstalledAddons(catalog);
|
|
|
|
|
const installedIds = new Set(installed.map((a) => a.id));
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
repository: `https://github.com/Emerald-Services/Addons`,
|
|
|
|
|
addons: catalog.map((addon) => ({
|
|
|
|
|
...addon,
|
|
|
|
|
installed: installedIds.has(addon.id),
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Addon catalog failed:', err);
|
|
|
|
|
res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: err.message || 'Could not load addon catalog from GitHub',
|
2026-04-21 13:37:39 +01:00
|
|
|
});
|
2026-05-16 15:10:27 +01:00
|
|
|
}
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/addons/installed', p('settings.view'), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
let catalog = [];
|
|
|
|
|
try {
|
|
|
|
|
catalog = await listCatalogAddons();
|
|
|
|
|
} catch {
|
|
|
|
|
catalog = [];
|
|
|
|
|
}
|
|
|
|
|
const installed = await listInstalledAddons(catalog);
|
|
|
|
|
const configs = listLocalAddonConfigs();
|
|
|
|
|
res.json({ success: true, data: { installed, configs } });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/addons/install', p('settings.update'), async (req, res) => {
|
|
|
|
|
const name = req.body?.name || req.body?.id;
|
|
|
|
|
if (!name) {
|
|
|
|
|
return res.status(400).json({ success: false, error: 'Missing addon name' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await installAddonFromCatalog(name);
|
|
|
|
|
let botRestart = { success: false, error: 'Bot client not available' };
|
|
|
|
|
if (this.client) {
|
|
|
|
|
botRestart = await this.restartBotAfterConfigSave();
|
|
|
|
|
notifyBotRestart(botRestart.success, botRestart.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: result,
|
|
|
|
|
botRestart,
|
|
|
|
|
message: botRestart.success
|
|
|
|
|
? `${result.addon.name} installed and bot restarted. Enable addons in Bot config if needed.`
|
|
|
|
|
: `${result.addon.name} installed but the bot could not restart. Check server logs.`,
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[API] Addon install failed:', err);
|
2026-05-16 15:10:27 +01:00
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/addons/configs', p('settings.view'), (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const files = listLocalAddonConfigs();
|
|
|
|
|
res.json({ success: true, data: files });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/addons/configs/json/:file', (req, res, next) => {
|
|
|
|
|
if (req.dashboardService) return next();
|
|
|
|
|
if (!canAccessConfig(req.dashboardPermissions, 'supportbot', 'view')) {
|
|
|
|
|
return forbid(res);
|
|
|
|
|
}
|
|
|
|
|
return next();
|
|
|
|
|
}, (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
try {
|
2026-05-16 15:10:27 +01:00
|
|
|
const parsed = readAddonConfig(req.params.file);
|
|
|
|
|
res.json({ success: true, data: parsed.data, filename: parsed.filename });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(404).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.put('/addons/configs', (req, res, next) => {
|
|
|
|
|
if (req.dashboardService) return next();
|
|
|
|
|
if (!canAccessConfig(req.dashboardPermissions, 'supportbot', 'edit')) {
|
|
|
|
|
return forbid(res);
|
|
|
|
|
}
|
|
|
|
|
return next();
|
|
|
|
|
}, async (req, res) => {
|
|
|
|
|
const { filename, data } = req.body || {};
|
|
|
|
|
if (!filename || !data || typeof data !== 'object') {
|
|
|
|
|
return res.status(400).json({ success: false, error: 'Missing filename or data' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
writeAddonConfig(filename, data);
|
|
|
|
|
let botRestart = { success: false, error: 'Bot client not available' };
|
|
|
|
|
if (this.client) {
|
|
|
|
|
botRestart = await this.restartBotAfterConfigSave();
|
|
|
|
|
notifyBotRestart(botRestart.success, botRestart.error);
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
2026-05-16 15:10:27 +01:00
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
botRestart,
|
|
|
|
|
message: botRestart.success
|
|
|
|
|
? 'Addon config saved and bot restarted.'
|
|
|
|
|
: 'Addon config saved but the bot could not restart.',
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
} catch (err) {
|
2026-05-16 15:10:27 +01:00
|
|
|
console.error('[API] Addon config save failed:', err);
|
|
|
|
|
res.status(500).json({ success: false, error: err.message });
|
2026-04-21 13:37:39 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Nexus Updater API ---
|
2026-05-16 15:10:27 +01:00
|
|
|
app.post('/system/update', p('settings.update'), async (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const { url, version } = req.body;
|
2026-05-16 15:10:27 +01:00
|
|
|
const downloadUrl = url || UPDATE_ZIP_URL;
|
2026-04-21 13:37:39 +01:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const axios = require('axios');
|
|
|
|
|
const { execSync } = require('child_process');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
|
|
|
|
console.log(`[Updater] Starting update to ${version || 'latest'}...`);
|
|
|
|
|
|
|
|
|
|
// 1. Download
|
|
|
|
|
const tempZip = `./update_${Date.now()}.zip`;
|
2026-05-16 15:10:27 +01:00
|
|
|
const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream' });
|
2026-04-21 13:37:39 +01:00
|
|
|
const writer = fs.createWriteStream(tempZip);
|
|
|
|
|
response.data.pipe(writer);
|
|
|
|
|
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
writer.on('finish', resolve);
|
|
|
|
|
writer.on('error', reject);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Extract to temp dir
|
|
|
|
|
const tempDir = `./temp_update_${Date.now()}`;
|
|
|
|
|
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir);
|
|
|
|
|
|
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
|
try {
|
|
|
|
|
if (isWindows) {
|
|
|
|
|
execSync(`powershell -Command "Expand-Archive -Path '${tempZip}' -DestinationPath '${tempDir}' -Force"`);
|
|
|
|
|
} else {
|
|
|
|
|
execSync(`unzip -o "${tempZip}" -d "${tempDir}"`);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw new Error(`Extraction failed. Ensure ${isWindows ? 'PowerShell' : 'unzip'} is installed. ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Identify inner directory (sometimes zips contain a root folder)
|
|
|
|
|
let sourcePath = tempDir;
|
|
|
|
|
const items = fs.readdirSync(tempDir);
|
|
|
|
|
if (items.length === 1 && fs.statSync(path.join(tempDir, items[0])).isDirectory()) {
|
|
|
|
|
sourcePath = path.join(tempDir, items[0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Smart Config Merge
|
|
|
|
|
const configFiles = ['supportbot', 'commands', 'messages', 'ticket-panel', 'supportbot-ai'];
|
|
|
|
|
configFiles.forEach(cf => {
|
|
|
|
|
const currentPath = `./Configs/${cf}.yml`;
|
|
|
|
|
const newPath = path.join(sourcePath, 'Configs', `${cf}.yml`);
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(currentPath) && fs.existsSync(newPath)) {
|
|
|
|
|
const currentCfg = yaml.load(fs.readFileSync(currentPath, 'utf8'));
|
|
|
|
|
const newCfg = yaml.load(fs.readFileSync(newPath, 'utf8'));
|
|
|
|
|
|
|
|
|
|
const merge = (target, source) => {
|
|
|
|
|
for (const key of Object.keys(source)) {
|
|
|
|
|
if (source[key] instanceof Object && !Array.isArray(source[key]) && target[key]) {
|
|
|
|
|
merge(target[key], source[key]);
|
|
|
|
|
} else if (target[key] === undefined) {
|
|
|
|
|
target[key] = source[key];
|
|
|
|
|
console.log(`[Updater] Added missing key: ${key} to ${cf}.yml`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
merge(currentCfg, newCfg);
|
|
|
|
|
fs.writeFileSync(currentPath, YAML.stringify(currentCfg));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
// 4. Overwrite core folders (keep Configs + Data)
|
2026-05-16 16:44:04 +01:00
|
|
|
const coreFolders = ['API', 'Commands', 'Events', 'Structures', 'public'];
|
2026-04-21 13:37:39 +01:00
|
|
|
coreFolders.forEach(folder => {
|
|
|
|
|
const src = path.join(sourcePath, folder);
|
|
|
|
|
if (fs.existsSync(src)) {
|
|
|
|
|
if (isWindows) {
|
|
|
|
|
execSync(`xcopy "${src}" ".\\${folder}" /E /I /Y`);
|
|
|
|
|
} else {
|
|
|
|
|
execSync(`cp -R "${src}/"* "./${folder}/"`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. Update package.json (version only)
|
|
|
|
|
const newPkgPath = path.join(sourcePath, 'package.json');
|
|
|
|
|
if (fs.existsSync(newPkgPath)) {
|
|
|
|
|
const currentPkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
|
|
|
|
const newPkg = JSON.parse(fs.readFileSync(newPkgPath, 'utf8'));
|
|
|
|
|
currentPkg.version = newPkg.version;
|
|
|
|
|
fs.writeFileSync('./package.json', JSON.stringify(currentPkg, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.unlinkSync(tempZip);
|
|
|
|
|
if (isWindows) {
|
|
|
|
|
execSync(`rmdir /s /q "${tempDir}"`);
|
|
|
|
|
} else {
|
|
|
|
|
execSync(`rm -rf "${tempDir}"`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
if (fs.existsSync(path.join(sourcePath, 'index.js'))) {
|
|
|
|
|
fs.copyFileSync(path.join(sourcePath, 'index.js'), './index.js');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
configStore.reloadBotConfigs();
|
|
|
|
|
if (this.client) {
|
|
|
|
|
await reloadBot(this.client, { refreshCommands: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateMessage =
|
|
|
|
|
'Update installed. Please restart your server (stop and run npm start again) to apply all file changes.';
|
|
|
|
|
notifyUpdateInstalled(updateMessage);
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
message: updateMessage,
|
|
|
|
|
restartRequired: true,
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Updater] Update failed:', err);
|
2026-05-16 15:10:27 +01:00
|
|
|
notificationStore.add({
|
|
|
|
|
type: 'error',
|
|
|
|
|
title: 'Update failed',
|
|
|
|
|
message: err.message,
|
|
|
|
|
href: '/settings',
|
|
|
|
|
});
|
2026-04-21 13:37:39 +01:00
|
|
|
res.status(500).json({ success: false, error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Transcript API ---
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/system/transcripts', p('transcripts'), (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const transcriptDir = './Data/Transcripts';
|
|
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(transcriptDir)) {
|
|
|
|
|
return res.json({ success: true, data: [] });
|
|
|
|
|
}
|
|
|
|
|
const files = fs.readdirSync(transcriptDir);
|
|
|
|
|
const transcripts = files
|
|
|
|
|
.filter(f => f.endsWith('-transcript.html'))
|
|
|
|
|
.map(f => {
|
|
|
|
|
const stats = fs.statSync(`${transcriptDir}/${f}`);
|
|
|
|
|
return {
|
|
|
|
|
id: f.replace('-transcript.html', ''),
|
|
|
|
|
filename: f,
|
|
|
|
|
createdAt: stats.mtime
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
|
res.json({ success: true, data: transcripts });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: 'Failed to list transcripts' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 15:10:27 +01:00
|
|
|
app.get('/system/transcripts/:id', p('transcripts'), (req, res) => {
|
2026-04-21 13:37:39 +01:00
|
|
|
const id = req.params.id.replace(/[^0-9]/g, ''); // Sanitize ID
|
|
|
|
|
const filePath = `./Data/Transcripts/${id}-transcript.html`;
|
|
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
|
|
|
return res.status(404).json({ success: false, error: 'Transcript not found' });
|
|
|
|
|
}
|
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
|
|
|
res.json({ success: true, data: content });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ success: false, error: 'Failed to read transcript' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = APIServer;
|