26.2-pre-2

This commit is contained in:
1tzEmerald committed 2026-08-06 16:30:04 +01:00
1 parent ea2a8c97ae
commit 762fcf45c3
26 files changed
+1252 -2963

No files matched your search

+464 -1
View File
@@ -98,6 +98,9 @@ class APIServer {
this.client = client;
this.app = express();
this.sseClients = new Set();
global.apiServer = this;
try {
const apiCfg = yaml.load(fs.readFileSync('./Configs/api.yml', 'utf8')).API;
this.config = apiCfg;
@@ -196,12 +199,25 @@ class APIServer {
req.dashboardPermissions = access.permissions;
req.dashboardRole = access.role;
req.dashboardIsOwner = access.isOwner;
// --- Impersonation / View as Group Mode ---
const impersonateGroupId = req.headers['x-impersonate-group'];
if (impersonateGroupId && (access.isOwner || hasPermission(access.permissions, 'users.manage'))) {
const groupStore = require('../Structures/DashboardGroupStore.js');
const group = groupStore.getGroup(impersonateGroupId);
if (group) {
req.dashboardPermissions = group.permissions;
req.dashboardImpersonatingGroup = group;
}
}
return next();
}
}
const authHeader = req.headers.authorization;
if (authHeader === `Bearer ${secret}`) {
const tokenQuery = req.query.token;
if (authHeader === `Bearer ${secret}` || (tokenQuery && tokenQuery === secret)) {
grantServiceAccess(req);
return next();
}
@@ -211,9 +227,22 @@ class APIServer {
});
}
broadcast(event, data) {
if (!this.sseClients || this.sseClients.size === 0) return;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const clientRes of this.sseClients) {
try {
clientRes.write(payload);
} catch (e) {
this.sseClients.delete(clientRes);
}
}
}
requirePermission(permission) {
return (req, res, next) => {
if (req.dashboardService) return next();
if (req.dashboardIsOwner || req.dashboardImpersonatingGroup) return next();
if (hasPermission(req.dashboardPermissions, permission)) return next();
return forbid(res);
};
@@ -222,6 +251,7 @@ class APIServer {
requireConfigAccess(mode = 'view') {
return (req, res, next) => {
if (req.dashboardService) return next();
if (req.dashboardIsOwner || req.dashboardImpersonatingGroup) return next();
const file = req.params?.file || req.body?.filename;
if (!file) return next();
if (!canAccessConfig(req.dashboardPermissions, file, mode)) {
@@ -478,6 +508,26 @@ h1{font-size:1.25rem}a{color:#a78bfa}</style></head><body>
this.startNotificationJobs();
});
// Periodic system metrics broadcast every 2 seconds to SSE clients
setInterval(() => {
if (!this.sseClients || this.sseClients.size === 0) return;
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const ramPercent = Math.round((usedMem / totalMem) * 100);
const cpuLoad = parseFloat(os.loadavg()[0].toFixed(2));
this.broadcast('metrics', {
timestamp: Date.now(),
ping: this.client?.ws?.ping ?? 0,
ram_percent: ramPercent,
ram_used_mb: Math.round(usedMem / 1024 / 1024),
ram_total_mb: Math.round(totalMem / 1024 / 1024),
cpu_load: cpuLoad,
uptime: process.uptime()
});
}, 2000);
this._httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(
@@ -543,6 +593,376 @@ h1{font-size:1.25rem}a{color:#a78bfa}</style></head><body>
const p = (permission) => this.requirePermission(permission);
const cv = (mode) => this.requireConfigAccess(mode);
// --- Real-time SSE Stream ---
app.get('/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
res.write('event: connected\ndata: {"connected": true}\n\n');
this.sseClients.add(res);
req.on('close', () => {
this.sseClients.delete(res);
});
});
// --- Ticket Chat & Management API ---
app.get('/system/tickets/open', p('tickets'), async (req, res) => {
try {
const allTickets = db.getAllTickets() || [];
const openTickets = allTickets.filter(t => t.status === 'open' || t.status === undefined || t.open);
const guildId = this.client?.guilds?.cache?.first()?.id || "";
res.json({
success: true,
data: {
guildId,
tickets: openTickets.map(t => ({
ticket_id: t.ticket_id,
user_id: t.user_id,
subject: t.subject || t.reason || 'Untitled Ticket',
description: t.description || '',
department: t.department || 'general',
priority: t.priority || 'medium',
created_at: t.created_at
}))
}
});
} catch (err) {
console.error('[API] Error fetching open tickets:', err);
res.status(500).json({ success: false, error: 'Failed to fetch open tickets' });
}
});
app.get('/system/tickets/:ticketId/messages', p('tickets'), async (req, res) => {
const { ticketId } = req.params;
try {
const channel = await this.client.channels.fetch(ticketId).catch(() => null);
if (!channel || !channel.isTextBased()) {
return res.status(404).json({ success: false, error: 'Ticket channel not found or inactive.' });
}
const fetchedMessages = await channel.messages.fetch({ limit: 50 });
const messages = Array.from(fetchedMessages.values())
.reverse()
.map(m => ({
id: m.id,
author: {
id: m.author.id,
username: m.author.username,
globalName: m.author.globalName || m.author.username,
avatar: m.author.displayAvatarURL({ extension: 'png', size: 64 }),
bot: m.author.bot
},
content: (() => {
if (m.content && m.content.trim()) return m.content;
if (Array.isArray(m.components) && m.components.length > 0) {
const texts = [];
for (const comp of m.components) {
const data = typeof comp.toJSON === 'function' ? comp.toJSON() : comp;
if (data.content) texts.push(data.content);
if (Array.isArray(data.components)) {
for (const child of data.components) {
const childData = typeof child.toJSON === 'function' ? child.toJSON() : child;
if (childData.content) texts.push(childData.content);
if (childData.label && (childData.type === 10 || childData.type === 'TEXT_DISPLAY')) {
texts.push(childData.label);
}
}
}
}
if (texts.length > 0) return texts.join('\n');
}
return m.content || '';
})(),
containerColor: (() => {
if (Array.isArray(m.components)) {
for (const comp of m.components) {
const data = typeof comp.toJSON === 'function' ? comp.toJSON() : comp;
if (data.accent_color) {
return `#${data.accent_color.toString(16).padStart(6, '0')}`;
}
}
}
return null;
})(),
embeds: m.embeds.map(e => ({
title: e.title || null,
description: e.description || null,
color: e.color ? `#${e.color.toString(16).padStart(6, '0')}` : '#5865F2',
author: e.author ? { name: e.author.name, iconURL: e.author.iconURL } : null,
fields: (e.fields || []).map(f => ({ name: f.name, value: f.value, inline: f.inline })),
footer: e.footer ? { text: e.footer.text, iconURL: e.footer.iconURL } : null,
thumbnail: e.thumbnail?.url ? { url: e.thumbnail.url } : null,
image: e.image?.url ? { url: e.image.url } : null,
timestamp: e.timestamp || null
})),
components: (m.components || []).map(c => {
const data = typeof c.toJSON === 'function' ? c.toJSON() : c;
return {
type: data.type,
components: (data.components || []).map(sub => {
const subData = typeof sub.toJSON === 'function' ? sub.toJSON() : sub;
return {
type: subData.type,
label: subData.label || subData.content || null,
style: subData.style || null,
url: subData.url || null,
customId: subData.custom_id || subData.customId || null,
emoji: subData.emoji ? (subData.emoji.name || subData.emoji.id) : null
};
})
};
}),
attachments: Array.from(m.attachments.values()).map(a => ({
id: a.id,
name: a.name,
url: a.url,
contentType: a.contentType
})),
createdAt: m.createdTimestamp
}));
res.json({ success: true, data: { ticketId, messages } });
} catch (err) {
console.error('[API] Error fetching ticket messages:', err);
res.status(500).json({ success: false, error: 'Failed to fetch ticket messages' });
}
});
app.post('/system/tickets/:ticketId/reply', p('tickets'), async (req, res) => {
const { ticketId } = req.params;
const { message, staffName, attachments } = req.body;
const hasText = message && message.trim();
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
if (!hasText && !hasAttachments) {
return res.status(400).json({ success: false, error: 'Message content or attachment is required.' });
}
try {
const channel = await this.client.channels.fetch(ticketId).catch(() => null);
if (!channel || !channel.isTextBased()) {
return res.status(404).json({ success: false, error: 'Ticket channel not found or inactive.' });
}
const { AttachmentBuilder } = require('discord.js');
const files = (attachments || []).map(att => {
const base64Data = att.data.includes(',') ? att.data.split(',')[1] : att.data;
const buffer = Buffer.from(base64Data, 'base64');
return new AttachmentBuilder(buffer, { name: att.filename || 'attachment.png' });
});
const apiConfig = require('../Structures/ConfigStore').api;
const liveChatMode = (apiConfig?.API?.LiveChat?.Mode || 'webhook').toLowerCase();
const staffTag = staffName ? staffName.trim() : (req.dashboardUser?.username || 'Staff');
const contentText = hasText ? message.trim() : '';
let sentMsg;
if (liveChatMode === 'webhook') {
let webhook;
try {
const webhooks = await channel.fetchWebhooks();
webhook = webhooks.find(wh => wh.name === 'SupportBot WebChat' || wh.owner?.id === this.client.user.id);
if (!webhook) {
webhook = await channel.createWebhook({
name: 'SupportBot WebChat',
avatar: this.client.user.displayAvatarURL()
});
}
} catch (whErr) {
console.warn('[API] Could not fetch/create webhook for channel, falling back to bot send:', whErr.message);
}
if (webhook) {
let avatarURL = this.client.user.displayAvatarURL({ extension: 'png' });
if (req.dashboardUser?.id && req.dashboardUser?.avatar) {
avatarURL = `https://cdn.discordapp.com/avatars/${req.dashboardUser.id}/${req.dashboardUser.avatar}.png`;
} else {
try {
const guild = channel.guild;
if (guild) {
const members = await guild.members.fetch().catch(() => null);
const member = members?.find(m =>
m.user.username.toLowerCase() === staffTag.toLowerCase() ||
m.displayName.toLowerCase() === staffTag.toLowerCase() ||
m.user.tag.toLowerCase() === staffTag.toLowerCase()
);
if (member) {
avatarURL = member.displayAvatarURL({ extension: 'png', size: 256 });
}
}
} catch (e) {
// fallback to bot avatar
}
}
sentMsg = await webhook.send({
content: contentText || undefined,
username: staffTag,
avatarURL,
files
});
} else {
const formattedContent = contentText ? `**[${staffTag}]** ${contentText}` : `**[${staffTag}]**`;
sentMsg = await channel.send({ content: formattedContent, files });
}
} else if (liveChatMode === 'bot_noprefix' || liveChatMode === 'bot_no_prefix' || liveChatMode === 'noprefix') {
// Bot mode without [Username] prefix
sentMsg = await channel.send({ content: contentText || undefined, files });
} else {
// Bot mode with [Username] prefix
const formattedContent = contentText ? `**[${staffTag}]** ${contentText}` : `**[${staffTag}]**`;
sentMsg = await channel.send({ content: formattedContent, files });
}
const sentAttachments = Array.from(sentMsg.attachments.values()).map(a => ({
id: a.id,
name: a.name,
url: a.url,
contentType: a.contentType
}));
const messageData = {
id: sentMsg.id,
author: {
id: sentMsg.author.id,
username: staffTag,
globalName: `${staffTag} (via Dashboard)`,
avatar: sentMsg.author.displayAvatarURL({ extension: 'png', size: 64 }),
bot: false,
isDashboard: true
},
content: contentText,
embeds: [],
attachments: sentAttachments,
createdAt: sentMsg.createdTimestamp
};
this.broadcast('ticket_message', {
ticketId,
message: messageData
});
res.json({ success: true, data: messageData });
} catch (err) {
console.error('[API] Error sending ticket reply:', err);
res.status(500).json({ success: false, error: 'Failed to send message to Discord.' });
}
});
app.delete('/system/tickets/:ticketId/messages/:messageId', p('tickets'), async (req, res) => {
const { ticketId, messageId } = req.params;
try {
const channel = await this.client.channels.fetch(ticketId).catch(() => null);
if (!channel || !channel.isTextBased()) {
return res.status(404).json({ success: false, error: 'Ticket channel not found.' });
}
const targetMsg = await channel.messages.fetch(messageId).catch(() => null);
if (!targetMsg) {
return res.status(404).json({ success: false, error: 'Message not found in Discord.' });
}
await targetMsg.delete();
this.broadcast('ticket_message_deleted', {
ticketId,
messageId
});
res.json({ success: true, message: 'Message deleted successfully.' });
} catch (err) {
console.error('[API] Error deleting message:', err);
res.status(500).json({ success: false, error: 'Failed to delete message in Discord.' });
}
});
app.patch('/system/tickets/:ticketId/messages/:messageId', p('tickets'), async (req, res) => {
const { ticketId, messageId } = req.params;
const { content } = req.body;
if (!content || !content.trim()) {
return res.status(400).json({ success: false, error: 'Content cannot be empty.' });
}
try {
const channel = await this.client.channels.fetch(ticketId).catch(() => null);
if (!channel || !channel.isTextBased()) {
return res.status(404).json({ success: false, error: 'Ticket channel not found.' });
}
const targetMsg = await channel.messages.fetch(messageId).catch(() => null);
if (!targetMsg) {
return res.status(404).json({ success: false, error: 'Message not found in Discord.' });
}
const updatedMsg = await targetMsg.edit({ content: content.trim() });
this.broadcast('ticket_message_updated', {
ticketId,
messageId,
content: content.trim()
});
res.json({ success: true, data: { id: updatedMsg.id, content: content.trim() } });
} catch (err) {
console.error('[API] Error editing message:', err);
res.status(500).json({ success: false, error: 'Failed to edit message in Discord.' });
}
});
app.post('/system/tickets/:ticketId/close', p('tickets'), async (req, res) => {
const { ticketId } = req.params;
const { reason } = req.body || {};
const staffTag = req.dashboardUser?.username || 'Staff';
try {
const channel = await this.client.channels.fetch(ticketId).catch(() => null);
if (!channel || !channel.isTextBased()) {
db.updateTicketStatus(ticketId, 'closed');
this.broadcast('ticket_closed', { ticket_id: ticketId });
return res.json({ success: true, message: 'Ticket marked as closed.' });
}
try {
const ticketManager = require('../Structures/TicketManager');
if (typeof ticketManager.createTranscript === 'function') {
const fakeInteraction = {
channel,
guild: channel.guild,
user: {
id: req.dashboardUser?.id || this.client.user.id,
tag: staffTag,
displayAvatarURL: () => this.client.user.displayAvatarURL()
},
client: this.client
};
await ticketManager.createTranscript(fakeInteraction, reason || 'Closed via Web Dashboard').catch(() => null);
}
} catch (trErr) {
console.warn('[API] Transcript generation on close skipped:', trErr.message);
}
db.updateTicketStatus(ticketId, 'closed');
this.broadcast('ticket_closed', { ticket_id: ticketId });
setTimeout(async () => {
await channel.delete(`Ticket closed by ${staffTag} via Web Dashboard`).catch(() => null);
}, 1500);
res.json({ success: true, message: 'Ticket closed successfully.' });
} catch (err) {
console.error('[API] Error closing ticket:', err);
res.status(500).json({ success: false, error: 'Failed to close ticket.' });
}
});
app.put('/branding', p('settings.update'), (req, res) => {
try {
const branding = dashboardBranding.updateBranding({
@@ -670,6 +1090,49 @@ h1{font-size:1.25rem}a{color:#a78bfa}</style></head><body>
}
});
// --- Custom Dashboard Groups API ---
const groupStore = require('../Structures/DashboardGroupStore.js');
app.get('/dashboard-groups', p('users.view'), (req, res) => {
try {
res.json({
success: true,
data: {
groups: groupStore.listGroups(),
},
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/dashboard-groups', p('users.manage'), (req, res) => {
try {
const group = groupStore.createGroup(req.body);
res.json({ success: true, data: group });
} catch (err) {
res.status(400).json({ success: false, error: err.message });
}
});
app.put('/dashboard-groups/:id', p('users.manage'), (req, res) => {
try {
const group = groupStore.updateGroup(req.params.id, req.body);
res.json({ success: true, data: group });
} catch (err) {
res.status(400).json({ success: false, error: err.message });
}
});
app.delete('/dashboard-groups/:id', p('users.manage'), (req, res) => {
try {
groupStore.deleteGroup(req.params.id);
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) {