diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a206026 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +Configs/supportbot.yml filter=supportbot-token +Configs/api.yml filter=api-secrets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86bc145 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules + +# Local database, runtime data, and transcripts +Data/*.db +Data/*.db-shm +Data/*.db-wal +Data/Transcripts/* +Data/dashboard-groups.json +Data/dashboard-users.json +Data/transcript-settings.json + +# Local log files +Logs/Output/* +Logs/Errors/* +Logs/Warns/* + +# Dashboard UI source at repo root only (do not ignore public/dashboard/) +/dashboard/ + +# Local secrets — use *.example as templates +Configs/api.yml diff --git a/API/server.js b/API/server.js index edb4b1d..48913ea 100644 --- a/API/server.js +++ b/API/server.js @@ -1,16 +1,106 @@ const express = require('express'); const cors = require('cors'); +const cookieParser = require('cookie-parser'); const fs = require('fs'); const os = require('os'); +const path = require('path'); const yaml = require('js-yaml'); -const YAML = require('yaml'); +const YAML = require('yaml'); const db = require('../Structures/Database.js'); +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 { evaluateDashboardAlerts } = require('../Structures/DashboardAlerts.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, + OAUTH_REDIRECT_COOKIE, + getTrustProxySetting, + resolveOAuthRedirectUri, + isSecureRequest, + buildAuthorizeUrl, + exchangeCode, + fetchDiscordUser, + createOAuthState, + oauthStateCookieOptions, + oauthRedirectCookieOptions, + 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 transcriptTemplate = require('../Structures/TranscriptTemplate.js'); +const transcriptStore = require('../Structures/TranscriptStore.js'); +const { refreshSlashCommands } = require('../Structures/BotReload.js'); +const { + isSetupComplete, + getSetupStatus, + validateSetupPayload, + applySetup, +} = require('../Structures/DashboardSetup.js'); +const dashboardBranding = require('../Structures/DashboardBranding.js'); +const { + listCatalogAddons, + listInstalledAddons, + installAddonFromCatalog, + listLocalAddonConfigs, + readAddonConfig, + writeAddonConfig, +} = require('../Structures/AddonCatalog.js'); + +const DASHBOARD_DIR = path.join(__dirname, '../public/dashboard'); +const UPDATE_REPO = 'Emerald-Services/SupportBot-Dashboard'; +const UPDATE_BRANCH = 'release'; +const UPDATE_ZIP_URL = `https://github.com/${UPDATE_REPO}/archive/refs/heads/${UPDATE_BRANCH}.zip`; class APIServer { constructor(client) { 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; @@ -21,30 +111,1210 @@ class APIServer { if (!this.config.Enabled) return; - this.app.use(cors()); - this.app.use(express.json()); + this.oauth = this.config.OAuth || {}; + bootstrapFromOAuth(this.oauth); - this.app.use((req, res, next) => { - const authHeader = req.headers.authorization; - if (!authHeader || authHeader !== `Bearer ${this.config.SecretKey}`) { - return res.status(401).json({ error: 'Unauthorized. Invalid Secret Key.' }); + const trustProxy = getTrustProxySetting(this.config); + if (trustProxy !== false) { + this.app.set('trust proxy', trustProxy); + } + + this.app.use(cors({ origin: true, credentials: true })); + this.app.use(cookieParser()); + this.app.use(express.json({ limit: '2mb' })); + + this.app.get('/api/branding', (req, res) => { + try { + const branding = dashboardBranding.readBranding(); + res.json({ + success: true, + data: dashboardBranding.publicPayload(branding), + }); + } catch (err) { + res.status(500).json({ success: false, error: 'Failed to load branding' }); } - next(); }); + this.app.get('/api/branding/favicon', (req, res) => { + try { + const favicon = dashboardBranding.getFaviconPath(); + if (!favicon) { + return res.status(404).end(); + } + res.setHeader('Cache-Control', 'public, max-age=300'); + res.type(favicon.mime); + return res.sendFile(favicon.filePath); + } catch (err) { + return res.status(500).end(); + } + }); + + 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), + setupComplete: isSetupComplete(), + guild, + }); + }); + + this.setupOnboardingRoutes(); + this.setupAuthRoutes(); + + this.app.get('/transcripts/:id', (req, res) => { + transcriptStore.servePublicTranscript(req, res); + }); + + const api = express.Router(); + api.use((req, res, next) => this.authenticate(req, res, next)); + this.apiRouter = api; this.setupRoutes(); + 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; + + // --- 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; + const tokenQuery = req.query.token; + if (authHeader === `Bearer ${secret}` || (tokenQuery && tokenQuery === secret)) { + grantServiceAccess(req); + return next(); + } + + return res.status(401).json({ + error: 'Unauthorized. Sign in with Discord.', + }); + } + + 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); + }; + } + + 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)) { + 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, + }, + ), + }); + } + + setupOnboardingRoutes() { + this.app.get('/api/setup/status', (req, res) => { + try { + res.json({ success: true, data: getSetupStatus(req) }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } + }); + + this.app.post('/api/setup/validate', async (req, res) => { + if (isSetupComplete()) { + return res.status(403).json({ + success: false, + error: 'Setup is already complete.', + }); + } + try { + const data = await validateSetupPayload(req.body || {}); + res.json({ success: true, data }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } + }); + + this.app.post('/api/setup/complete', async (req, res) => { + if (isSetupComplete()) { + return res.status(403).json({ + success: false, + error: 'Setup is already complete.', + }); + } + + try { + const body = req.body || {}; + const validation = await validateSetupPayload(body); + if (!validation.ok) { + return res.status(400).json({ + success: false, + error: 'Fix validation errors before saving.', + data: validation, + }); + } + + applySetup(body); + this.config = configStore.api?.API || configStore.api; + this.oauth = this.config.OAuth || {}; + bootstrapFromOAuth(this.oauth); + + let botRestart = { success: false, error: 'Bot client not available' }; + if (this.client) { + try { + botRestart = await restartDiscordBot(this.client); + } catch (restartErr) { + botRestart = { success: false, error: restartErr.message }; + } + } + + res.json({ + success: true, + message: 'Setup saved. Restart the server for changes to take effect, then sign in with Discord.', + data: { setupComplete: true, botRestart }, + }); + } catch (err) { + console.error('[API] Setup failed:', err); + res.status(400).json({ success: false, error: err.message }); + } + }); + } + + setupAuthRoutes() { + const secret = this.config.SecretKey; + const oauth = this.oauth; + const apiConfig = this.config; + + this.app.get('/api/auth/discord', (req, res) => { + if (!isSetupComplete() || !isOAuthConfigured(oauth)) { + return res.redirect('/setup'); + } + + const redirectUri = resolveOAuthRedirectUri(req, oauth, apiConfig); + const state = createOAuthState(); + const cookieOpts = oauthStateCookieOptions(req, oauth, apiConfig); + + res.cookie(OAUTH_STATE_COOKIE, state, cookieOpts); + res.cookie(OAUTH_REDIRECT_COOKIE, redirectUri, oauthRedirectCookieOptions(req, oauth, apiConfig)); + res.redirect(buildAuthorizeUrl(oauth, state, redirectUri)); + }); + + this.app.get('/api/auth/discord/callback', async (req, res) => { + if (!isSetupComplete() || !isOAuthConfigured(oauth)) { + return res.redirect('/setup'); + } + + 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]; + const savedRedirect = req.cookies?.[OAUTH_REDIRECT_COOKIE]; + const cookieOpts = oauthStateCookieOptions(req, oauth, apiConfig); + + res.clearCookie(OAUTH_STATE_COOKIE, { path: '/', secure: cookieOpts.secure }); + res.clearCookie(OAUTH_REDIRECT_COOKIE, { path: '/', secure: cookieOpts.secure }); + + if (!savedState || savedState !== state) { + return res.redirect('/login?error=invalid_state'); + } + + const redirectUri = + savedRedirect || resolveOAuthRedirectUri(req, oauth, apiConfig); + + try { + const accessToken = await exchangeCode(oauth, String(code), redirectUri); + 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(undefined, { + secure: isSecureRequest(req, oauth, apiConfig), + }), + ); + 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 }); + }); + } + + 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; + } + + setupDashboard() { + 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(` +SupportBot Dashboard + +

Dashboard files not found

+

The API is running, but public/dashboard/ is missing from this install (no index.html).

+

Clone or download the repo including public/dashboard/, then restart the bot.

+

Check API health

+`); + }); + return; + } + + console.log(`[API] Serving dashboard from ${dashboardDir}`); + + this.app.use( + express.static(dashboardDir, { + index: 'index.html', + fallthrough: true, + }), + ); + + 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) => { + if (err) next(err); + }); + }); + + console.log('[API] Dashboard available at http://localhost:' + port); } start(port) { + if (this._httpServer) { + return this._httpServer; + } + const listenPort = port || this.config.Port || 3000; - this.app.listen(listenPort, () => { + transcriptStore.startAutoDeleteScheduler(); + this._httpServer = this.app.listen(listenPort, () => { console.log(`[API] Server running on port ${listenPort}`); + 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( + `[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), + ]; } setupRoutes() { - this.app.get('/api/stats', async (req, res) => { + const app = this.apiRouter; + const oauth = this.oauth; + 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.post('/system/tickets/create', p('tickets'), async (req, res) => { + try { + const { department, subject, reason, userId } = req.body; + if (!this.client?.user) { + return res.status(503).json({ success: false, error: 'Bot is not connected to Discord.' }); + } + + const guild = this.client.guilds.cache.first(); + if (!guild) { + return res.status(404).json({ success: false, error: 'No connected Discord server found.' }); + } + + const supportbot = configStore.supportbot; + const cmdconfig = configStore.commands; + + const deptKey = department || supportbot?.Ticket?.DepartmentSystem?.DefaultDepartment || 'general'; + const deptConfig = supportbot?.Ticket?.DepartmentSystem?.Departments?.[deptKey]; + if (deptConfig && deptConfig.Enabled === false) { + return res.status(400).json({ success: false, error: `Ticket department '${deptKey}' is currently disabled.` }); + } + + const targetUserId = userId || req.dashboardUser?.id || guild.ownerId; + let targetUser = await this.client.users.fetch(targetUserId).catch(() => null); + if (!targetUser) { + targetUser = this.client.user; + } + + const openCmd = this.client.commands.get(cmdconfig?.OpenTicket?.Command || "new"); + if (!openCmd) { + return res.status(500).json({ success: false, error: 'Open ticket command not found in bot.' }); + } + + const fakeInteraction = { + client: this.client, + guild, + user: targetUser, + member: await guild.members.fetch(targetUser.id).catch(() => null), + channel: guild.channels.cache.first(), + department: deptKey, + reason: subject || reason || "Opened via Web Dashboard", + priority: deptConfig?.DefaultPriority || supportbot?.Ticket?.PrioritySystem?.DefaultPriority || "medium", + options: { + getString: (name) => { + if (name === "department") return deptKey; + if (name === "reason") return subject || reason || "Opened via Web Dashboard"; + if (name === "priority") return deptConfig?.DefaultPriority || supportbot?.Ticket?.PrioritySystem?.DefaultPriority || "medium"; + return null; + } + }, + isCommand: () => false, + isChatInputCommand: () => false, + isButton: () => false, + isStringSelectMenu: () => false, + isModalSubmit: () => false, + deferred: false, + replied: false, + deferReply: async () => {}, + reply: async (payload) => { fakeInteraction.repliedPayload = payload; }, + followUp: async (payload) => { fakeInteraction.followUpPayload = payload; }, + editReply: async (payload) => { fakeInteraction.editReplyPayload = payload; } + }; + + await openCmd.run(fakeInteraction); + + res.json({ + success: true, + message: `Ticket successfully created for department: ${deptConfig?.Name || deptKey}!`, + }); + } catch (err) { + console.error('[API] Error creating ticket:', err); + res.status(500).json({ success: false, error: err.message || 'Failed to create ticket' }); + } + }); + + 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({ + title: req.body?.title, + pageTitle: req.body?.pageTitle, + }); + res.json({ + success: true, + data: dashboardBranding.publicPayload(branding), + message: 'Branding saved.', + }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } + }); + + app.post('/branding/favicon', p('settings.update'), (req, res) => { + try { + const branding = dashboardBranding.saveFaviconFromBase64(req.body?.dataUrl); + res.json({ + success: true, + data: dashboardBranding.publicPayload(branding), + message: 'Favicon updated.', + }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } + }); + + app.delete('/branding/favicon', p('settings.update'), (req, res) => { + try { + const branding = dashboardBranding.clearFavicon(); + res.json({ + success: true, + data: dashboardBranding.publicPayload(branding), + message: 'Favicon removed.', + }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } + }); + + app.post('/branding/reset', p('settings.update'), (req, res) => { + try { + const branding = dashboardBranding.resetBranding(); + res.json({ + success: true, + data: dashboardBranding.publicPayload(branding), + message: 'Branding reset to defaults.', + }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } + }); + + app.post('/setup/reset', p('settings.update'), (req, res) => { + try { + const apiPath = path.join(__dirname, '../Configs/api.yml'); + if (fs.existsSync(apiPath)) { + const raw = fs.readFileSync(apiPath, 'utf8'); + const parsed = yaml.load(raw) || {}; + if (parsed.API && parsed.API.OAuth) { + parsed.API.OAuth.Enabled = false; + parsed.API.OAuth.ClientId = 'YOUR_CLIENT_ID'; + parsed.API.OAuth.ClientSecret = 'YOUR_CLIENT_SECRET'; + parsed.API.OAuth.OwnerUserIds = []; + const doc = new YAML.Document(parsed); + fs.writeFileSync(apiPath, doc.toString(), 'utf8'); + configStore.reloadFile('api'); + } + } + res.json({ + success: true, + message: 'Dashboard reset to setup mode. Restarting...', + restartRequired: true + }); + + setTimeout(() => restartDiscordBot(), 500); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } + }); + + app.get('/dashboard-users', p('users.view'), (req, res) => { + try { + res.json({ + success: true, + data: { + users: listUsers(oauth, this.client), + 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 }); + } + }); + + // --- 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) { + 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('/alerts', p('overview'), async (req, res) => { + try { + 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 data = await evaluateDashboardAlerts({ + client: this.client, + configStore, + transcriptCount, + }); + + res.json({ success: true, data }); + } catch (err) { + console.error('[API] Error fetching alerts:', 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.', + }); + } + if (req.query.refresh === '1') { + clearGuildResourcesCache(); + } + 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) => { + try { + if (this.client.__restarting) { + return res.json({ + success: false, + error: 'Bot is restarting after a config change. Try again shortly.', + }); + } if (!this.client.user) { return res.json({ success: false, error: 'Bot is still starting up.' }); } @@ -55,8 +1325,20 @@ class APIServer { }); const allTickets = db.getAllTickets() || []; - const openTickets = allTickets.filter(t => t.open).length; - const closedTickets = allTickets.filter(t => !t.open).length; + 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, + }); const totalMem = os.totalmem(); const freeMem = os.freemem(); @@ -71,6 +1353,7 @@ class APIServer { username: this.client.user.username, id: this.client.user.id, avatar: this.client.user.displayAvatarURL(), + inviteUrl: getBotInviteUrl(this.client.user.id), version: require('../package.json').version, ping: this.client.ws.ping, uptime: process.uptime(), @@ -82,6 +1365,7 @@ class APIServer { open: openTickets, closed: closedTickets }, + modules, hosting: { ram_used: (usedMem / 1024 / 1024 / 1024).toFixed(2) + ' GB', ram_total: (totalMem / 1024 / 1024 / 1024).toFixed(2) + ' GB', @@ -97,8 +1381,8 @@ class APIServer { } }); - this.app.get('/api/configs/:file', (req, res) => { - const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai']; + app.get('/configs/:file', cv('view'), (req, res) => { + const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai', 'api']; const file = req.params.file; if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' }); try { @@ -109,21 +1393,51 @@ class APIServer { } }); - this.app.put('/api/configs/raw', (req, res) => { - const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai']; + app.put('/configs/raw', cv('edit'), async (req, res) => { + if (!hasPermission(req.dashboardUser?.permissions, 'configs.raw_yaml')) { + return res.status(403).json({ success: false, error: 'Forbidden: Raw YAML editing requires the Raw YAML Editor permission.' }); + } + const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai', 'api']; const file = req.body.filename; - const content = req.body.content; + let content = req.body.content; if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' }); try { + 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]}"`, + ); + } + } + } fs.writeFileSync(`./Configs/${file}.yml`, content); - res.json({ success: true }); + 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.', + }); } catch (err) { res.status(500).json({ success: false, error: 'Failed to write config' }); } }); - this.app.get('/api/configs/json/:file', (req, res) => { - const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai']; + app.get('/configs/json/:file', cv('view'), (req, res) => { + const validFiles = ['supportbot', 'ticket-panel', 'commands', 'messages', 'supportbot-ai', 'api']; const file = req.params.file; if (!validFiles.includes(file)) return res.status(400).json({ success: false, error: 'Invalid file' }); try { @@ -134,82 +1448,358 @@ class APIServer { } }); - this.app.post('/api/configs/update-fields/multi', (req, res) => { + app.post('/configs/update-fields/multi', async (req, res) => { const updates = req.body; if (!updates || typeof updates !== 'object') return res.status(400).json({ success: false, error: 'Invalid updates' }); + + 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; + } + try { + const touched = new Set(); for (const [keyPath, value] of Object.entries(updates)) { - const [fileName, path] = keyPath.split(':'); + const [fileName, fieldPath] = keyPath.split(':'); const fileContent = fs.readFileSync(`./Configs/${fileName}.yml`, 'utf8'); const doc = YAML.parseDocument(fileContent); - const keys = path.split('.'); + const keys = fieldPath.split('.'); doc.setIn(keys, value); fs.writeFileSync(`./Configs/${fileName}.yml`, doc.toString()); + touched.add(fileName); } - res.json({ success: true }); + + 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.', + }); } catch (err) { console.error('[API] Update failed:', err); res.status(500).json({ success: false, error: err.message }); } }); - this.app.post('/api/addons/install', async (req, res) => { - const { url, filename } = req.body; - if (!url || !filename) return res.status(400).json({ success: false, error: 'Missing url or filename' }); + 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() }, + }); + }); + + app.post('/system/reload-configs', p('settings.update'), async (req, res) => { try { - const axios = require('axios'); - const response = await axios({ - method: 'get', - url: url, - responseType: 'stream' - }); - - // Ensure Addons directory exists - if (!fs.existsSync('./Addons')) { - fs.mkdirSync('./Addons'); + 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 writer = fs.createWriteStream(`./Addons/${filename}`); - response.data.pipe(writer); - - writer.on('finish', () => { - res.json({ success: true, message: `Addon ${filename} installed successfully.` }); + const result = await reloadBot(this.client, { + fileKey: file || null, + refreshCommands: !file || file === 'commands', }); - - writer.on('error', (err) => { - console.error('[API] Download failed:', err); - res.status(500).json({ success: false, error: 'Failed to write addon file' }); + res.json({ + success: true, + reloaded: result.reloaded, + commands: result.commands, }); - } catch (err) { - console.error('[API] Addon install failed:', err); - res.status(500).json({ success: false, error: 'Failed to download addon' }); + console.error('[API] Reload failed:', err); + res.status(500).json({ success: false, error: err.message }); } }); - this.app.post('/api/addons/push', (req, res) => { - const { filename, content } = req.body; - if (!filename || !content) return res.status(400).json({ success: false, error: 'Missing filename or content' }); - - const sanitizedFilename = filename.replace(/[^a-z0-9_.-]/gi, '_'); - + app.get('/system/logs/meta', p('logs'), (req, res) => { try { - if (!fs.existsSync('./Addons')) { - fs.mkdirSync('./Addons'); - } - fs.writeFileSync(`./Addons/${sanitizedFilename}`, content); - res.json({ success: true, message: `Addon ${sanitizedFilename} deployed successfully.` }); + res.json({ success: true, data: { types: LOG_TYPES, files: getLogMeta() } }); } catch (err) { - console.error('[API] Push failed:', err); - res.status(500).json({ success: false, error: 'Failed to write addon file' }); + 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; + + 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' }); + } + } + + 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); + + res.json({ + success: true, + data: { + entries, + cursor: nextCursor, + }, + }); + } 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.' }); + } + }); + + 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://emeraldsrv.dev`, + 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 Emerald API', + }); + } + }); + + 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.`, + }); + } catch (err) { + console.error('[API] Addon install failed:', err); + res.status(500).json({ success: false, error: err.message }); + } + }); + + app.post('/addons/build', p('settings.update'), async (req, res) => { + try { + const { name, description, permission, embed } = req.body; + if (!name || !name.match(/^[a-z0-9_-]+$/i)) { + return res.status(400).json({ success: false, error: "Invalid addon name" }); + } + + const safeName = name.toLowerCase(); + const addonPath = path.join(process.cwd(), 'Addons', `${safeName}.js`); + + if (fs.existsSync(addonPath)) { + return res.status(400).json({ success: false, error: "An addon with this name already exists" }); + } + + const jsContent = `const Discord = require("discord.js"); +const { Command } = require('../Structures/Addon.js'); +const supportbot = require("../Structures/ConfigStore").supportbot; + +module.exports = new Command({ + name: "${safeName}", + description: ${JSON.stringify(description || "Custom command")}, + options: [], + permissions: ${permission && permission !== "none" ? `["${permission}"]` : "[]"}, + + async run(interaction) { + const CustomEmbed = new Discord.EmbedBuilder() + ${embed?.title ? `.setTitle(${JSON.stringify(embed.title)})` : ""} + .setDescription(${JSON.stringify(embed?.description || "Hello!")}) + .setColor(supportbot.Embed.Colours.${embed?.color || "General"}); + + interaction.reply({ + embeds: [CustomEmbed], + }); + }, +}); +`; + if (!fs.existsSync(path.join(process.cwd(), 'Addons'))) { + fs.mkdirSync(path.join(process.cwd(), 'Addons'), { recursive: true }); + } + + fs.writeFileSync(addonPath, jsContent); + + 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, + message: botRestart.success ? "Bot restarted automatically to load your new addon." : "Restart the bot to load your new command.", + botRestart + }); + } catch (err) { + console.error("[API] Addon build failed:", err); + res.status(500).json({ success: false, error: "Failed to build addon" }); + } + }); + + 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 }); + } + }); + + 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) => { + try { + 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); + } + res.json({ + success: true, + botRestart, + message: botRestart.success + ? 'Addon config saved and bot restarted.' + : 'Addon config saved but the bot could not restart.', + }); + } catch (err) { + console.error('[API] Addon config save failed:', err); + res.status(500).json({ success: false, error: err.message }); } }); // --- Nexus Updater API --- - this.app.post('/api/system/update', async (req, res) => { + app.post('/system/update', p('settings.update'), async (req, res) => { const { url, version } = req.body; - if (!url) return res.status(400).json({ success: false, error: 'Missing update URL' }); + const downloadUrl = url || UPDATE_ZIP_URL; try { const axios = require('axios'); @@ -220,7 +1810,7 @@ class APIServer { // 1. Download const tempZip = `./update_${Date.now()}.zip`; - const response = await axios({ method: 'get', url: url, responseType: 'stream' }); + const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream' }); const writer = fs.createWriteStream(tempZip); response.data.pipe(writer); @@ -276,8 +1866,8 @@ class APIServer { } }); - // 4. Overwrite Core Folders - const coreFolders = ['API', 'Commands', 'Events', 'Structures']; + // 4. Overwrite core folders (keep Configs + Data) + const coreFolders = ['API', 'Commands', 'Events', 'Structures', 'public']; coreFolders.forEach(folder => { const src = path.join(sourcePath, folder); if (fs.existsSync(src)) { @@ -290,11 +1880,20 @@ class APIServer { }); // 5. Update package.json (version only) - const newPkgPath = path.join(sourcePath, 'package.json'); + const reqRepoId = req.body.repoId; + let newPkgPath = path.join(sourcePath, 'package.json'); + if (reqRepoId === 'dashboard') { + newPkgPath = path.join(sourcePath, 'dashboard', '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; + if (reqRepoId === 'dashboard') { + currentPkg.dashboardVersion = newPkg.version; + } else { + currentPkg.version = newPkg.version; + } fs.writeFileSync('./package.json', JSON.stringify(currentPkg, null, 2)); } @@ -305,55 +1904,231 @@ class APIServer { execSync(`rm -rf "${tempDir}"`); } - res.json({ success: true, message: 'Update installed successfully. Bot is restarting...' }); - - // Reboot - setTimeout(() => process.exit(0), 1000); + 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, + }); } catch (err) { console.error('[Updater] Update failed:', err); + notificationStore.add({ + type: 'error', + title: 'Update failed', + message: err.message, + href: '/settings', + }); res.status(500).json({ success: false, error: err.message }); } }); // --- Transcript API --- - this.app.get('/api/system/transcripts', (req, res) => { - const transcriptDir = './Data/Transcripts'; + const canManageTranscripts = (req) => { + if (req.dashboardService) return true; + if (!hasPermission(req.dashboardPermissions, 'transcripts')) return false; + if (req.dashboardRole === 'viewer') return false; + return true; + }; + + const canEditTranscriptTemplate = (req) => { + if (req.dashboardService) return true; + if (!hasPermission(req.dashboardPermissions, 'transcripts')) return false; + if (req.dashboardRole === 'viewer') return false; + return true; + }; + + app.get('/system/transcript-template', p('transcripts'), (req, res) => { 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: transcriptTemplate.getTemplate() }); + } catch (err) { + res.status(500).json({ success: false, error: 'Failed to load transcript template' }); + } + }); + + app.put('/system/transcript-template', p('transcripts'), (req, res) => { + if (!canEditTranscriptTemplate(req)) { + return forbid(res); + } + try { + const saved = transcriptTemplate.saveTemplate(req.body); + res.json({ + success: true, + data: saved, + message: 'Transcript appearance saved. New transcripts will use this design.', + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message || 'Failed to save template' }); + } + }); + + app.post('/system/transcript-template/preview', p('transcripts'), (req, res) => { + try { + const html = transcriptTemplate.renderPreview(req.body); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.send(html); + } catch (err) { + res.status(500).type('html').send('

Preview failed

'); + } + }); + + app.get('/system/transcript-settings', p('transcripts'), (req, res) => { + try { + const settings = transcriptStore.getSettings(); + res.json({ + success: true, + data: { + ...settings, + publicBasePath: '/transcripts', + }, + }); + } catch (err) { + res.status(500).json({ success: false, error: 'Failed to load transcript settings' }); + } + }); + + app.put('/system/transcript-settings', p('transcripts'), (req, res) => { + if (!canManageTranscripts(req)) { + return forbid(res); + } + try { + const saved = transcriptStore.saveSettings(req.body); + res.json({ + success: true, + data: { + ...saved, + publicBasePath: '/transcripts', + }, + message: 'Transcript settings saved.', + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message || 'Failed to save settings' }); + } + }); + + app.post('/system/transcripts/purge', p('transcripts'), (req, res) => { + if (!canManageTranscripts(req)) { + return forbid(res); + } + try { + const result = transcriptStore.purgeExpiredTranscripts(); + res.json({ + success: true, + data: result, + message: result.skipped + ? 'Auto-delete is disabled. Enable it in settings to purge old transcripts.' + : `Removed ${result.deleted} transcript(s).`, + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message || 'Purge failed' }); + } + }); + + app.get('/system/transcripts', p('transcripts'), (req, res) => { + try { + transcriptStore.purgeExpiredTranscripts(); + const transcripts = transcriptStore.listTranscripts(); res.json({ success: true, data: transcripts }); } catch (err) { res.status(500).json({ success: false, error: 'Failed to list transcripts' }); } }); - this.app.get('/api/system/transcripts/:id', (req, res) => { - const id = req.params.id.replace(/[^0-9]/g, ''); // Sanitize ID - const filePath = `./Data/Transcripts/${id}-transcript.html`; + app.delete('/system/transcripts/:id', p('transcripts'), (req, res) => { + if (!canManageTranscripts(req)) { + return forbid(res); + } try { - if (!fs.existsSync(filePath)) { + const removed = transcriptStore.deleteTranscript(req.params.id); + if (!removed) { return res.status(404).json({ success: false, error: 'Transcript not found' }); } - const content = fs.readFileSync(filePath, 'utf8'); - res.json({ success: true, data: content }); + res.json({ success: true, message: 'Transcript deleted.' }); + } catch (err) { + res.status(500).json({ success: false, error: 'Failed to delete transcript' }); + } + }); + + app.get('/system/transcripts/:id', p('transcripts'), (req, res) => { + try { + const transcript = transcriptStore.readTranscriptFile(req.params.id); + if (!transcript) { + return res.status(404).json({ success: false, error: 'Transcript not found' }); + } + res.json({ success: true, data: transcript.content }); } catch (err) { res.status(500).json({ success: false, error: 'Failed to read transcript' }); } }); + + app.get('/system/transcripts/:id/view', p('transcripts'), (req, res) => { + try { + const transcript = transcriptStore.readTranscriptFile(req.params.id); + if (!transcript) { + return res.status(404).type('html').send( + '

Transcript not found

', + ); + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=60'); + res.send(transcript.content); + } catch (err) { + res.status(500).type('html').send( + '

Failed to load transcript

', + ); + } + }); + + app.get('/system/transcripts/:id/download', p('transcripts'), (req, res) => { + try { + const transcript = transcriptStore.readTranscriptFile(req.params.id); + if (!transcript) { + return res.status(404).json({ success: false, error: 'Transcript not found' }); + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${transcript.filename}"`, + ); + res.send(transcript.content); + } catch (err) { + res.status(500).json({ success: false, error: 'Failed to download transcript' }); + } + }); + + app.get('/system/tickets/open', p('overview'), (req, res) => { + try { + const db = require('../Structures/Database.js'); + const configStore = require('../Structures/ConfigStore.js'); + const guildId = configStore.supportbot.General.GuildId; + + const openTickets = db.getAllTickets().filter(t => t.status === 'open'); + + res.json({ + success: true, + data: { + guildId, + tickets: openTickets + } + }); + } catch (err) { + console.error("Open tickets API error:", err); + res.status(500).json({ success: false, error: 'Failed to fetch open tickets' }); + } + }); } } diff --git a/Addons/addon.js b/Addons/addon.js index 7609fd7..675bc57 100644 --- a/Addons/addon.js +++ b/Addons/addon.js @@ -2,7 +2,7 @@ const Discord = require("discord.js"); const fs = require("fs"); const yaml = require("js-yaml"); const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; module.exports = new Command({ name: "addon", diff --git a/Commands/General/ai.js b/Commands/General/ai.js index 08cbffa..804fbbc 100644 --- a/Commands/General/ai.js +++ b/Commands/General/ai.js @@ -5,9 +5,9 @@ const fs = require("fs"); const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, MessageFlags } = require("discord.js"); const yaml = require("js-yaml"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const supportbotai = yaml.load(fs.readFileSync("./Configs/supportbot-ai.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const supportbotai = require("../../Structures/ConfigStore").supportbotAi; const Command = require("../../Structures/Command.js"); const AIDatabase = require("../../Structures/AIDatabase.js"); diff --git a/Commands/General/help.js b/Commands/General/help.js index 9b0e469..c475bb5 100644 --- a/Commands/General/help.js +++ b/Commands/General/help.js @@ -2,8 +2,8 @@ const fs = require("fs"); const Discord = require("discord.js"); const yaml = require("js-yaml"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; const Command = require("../../Structures/Command.js"); @@ -54,7 +54,7 @@ module.exports = new Command({ iconURL: interaction.user.displayAvatarURL(), }); - interaction.reply({ + await interaction.reply({ flags: Discord.MessageFlags.Ephemeral, embeds: [HelpEmbed1], }); diff --git a/Commands/General/info.js b/Commands/General/info.js index fe4dba3..10a517f 100644 --- a/Commands/General/info.js +++ b/Commands/General/info.js @@ -1,22 +1,11 @@ // SupportBot | Emerald Services // Info Command -const fs = require("fs"); - const Discord = require("discord.js"); -const yaml = require("js-yaml"); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8") -); - -const cmdconfig = yaml.load( - fs.readFileSync("./Configs/commands.yml", "utf8") -); - -const msgconfig = yaml.load( - fs.readFileSync("./Configs/messages.yml", "utf8") -); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const Command = require("../../Structures/Command.js"); diff --git a/Commands/General/ping.js b/Commands/General/ping.js index 99bb62c..9d50536 100644 --- a/Commands/General/ping.js +++ b/Commands/General/ping.js @@ -5,10 +5,8 @@ const fs = require("fs"); const Discord = require("discord.js"); const yaml = require("js-yaml"); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8") -); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; const Command = require("../../Structures/Command.js"); @@ -19,15 +17,13 @@ module.exports = new Command({ permissions: cmdconfig.Ping.Permission, async run(interaction) { - let disableCommand = true; - const PingEmbed = new Discord.EmbedBuilder() .setDescription( - `:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\`` + `:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\``, ) .setColor(supportbot.Embed.Colours.General); - interaction.reply({ + await interaction.reply({ embeds: [PingEmbed], }); }, diff --git a/Commands/General/profile.js b/Commands/General/profile.js index c48cd5d..f1e20b7 100644 --- a/Commands/General/profile.js +++ b/Commands/General/profile.js @@ -20,9 +20,9 @@ const yaml = require("js-yaml"); const db = require("../../Structures/Database.js"); const Command = require("../../Structures/Command.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const clockedInUsers = new Set(); @@ -177,6 +177,37 @@ function buildTicketStatsContainer(viewingUserId, ticketsClaimed, ticketsOpen, a return container; } +function getStaffTicketStats(viewingUserId) { + const allTickets = typeof db.getAllTickets === "function" ? db.getAllTickets() : []; + const ticketsClaimed = allTickets.filter( + (t) => t.claimedBy === viewingUserId || t.claimed_by === viewingUserId, + ); + const ticketsOpen = ticketsClaimed.filter( + (t) => (t.status || "").toLowerCase() === "open", + ); + + let totalResponseTime = 0; + let responseCount = 0; + + ticketsClaimed.forEach((t) => { + const claimedAt = t.claimedAt || t.claimed_at; + const createdAt = t.createdAt || t.created_at; + if (claimedAt && createdAt) { + const diff = new Date(claimedAt).getTime() - new Date(createdAt).getTime(); + if (diff > 0) { + totalResponseTime += diff; + responseCount++; + } + } + }); + + const avgMinutes = responseCount + ? Math.round(totalResponseTime / responseCount / 60000) + : 0; + + return { ticketsClaimed, ticketsOpen, avgMinutes }; +} + function buildOpenTicketsContainer(ticketsOpen) { const container = new ContainerBuilder(); @@ -192,7 +223,9 @@ function buildOpenTicketsContainer(ticketsOpen) { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - ticketsOpen.length ? ticketsOpen.map((t) => `<#${t.id}>`).join("\n") : "No active tickets.", + ticketsOpen.length + ? ticketsOpen.map((t) => `<#${t.ticket_id || t.id}>`).join("\n") + : "No active tickets.", ), ); @@ -352,31 +385,7 @@ module.exports = new Command({ } if (i.customId === "viewTicketStats") { - let TicketData = { tickets: [] }; - - try { - TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8")); - } catch (error) { - console.error("Error reading TicketData.json:", error); - } - - const ticketsClaimed = TicketData.tickets.filter((t) => t.claimedBy === viewingUserId); - const ticketsOpen = ticketsClaimed.filter((t) => t.open); - const totalTickets = ticketsClaimed.length; - - let totalResponseTime = 0; - let responseCount = 0; - - ticketsClaimed.forEach((t) => { - if (t.claimedAt && t.createdAt) { - totalResponseTime += new Date(t.claimedAt) - new Date(t.createdAt); - responseCount++; - } - }); - - const avgMinutes = responseCount - ? Math.round(totalResponseTime / responseCount / 60000) - : 0; + const { ticketsClaimed, ticketsOpen, avgMinutes } = getStaffTicketStats(viewingUserId); const statsContainer = buildTicketStatsContainer( viewingUserId, @@ -403,17 +412,7 @@ module.exports = new Command({ } if (i.customId === "showOpenTickets") { - let TicketData = { tickets: [] }; - - try { - TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8")); - } catch (error) { - console.error("Error reading TicketData.json:", error); - } - - const ticketsOpen = TicketData.tickets.filter( - (t) => t.claimedBy === viewingUserId && t.open, - ); + const { ticketsOpen } = getStaffTicketStats(viewingUserId); const openContainer = buildOpenTicketsContainer(ticketsOpen); @@ -431,30 +430,7 @@ module.exports = new Command({ } if (i.customId === "backToStats") { - let TicketData = { tickets: [] }; - - try { - TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8")); - } catch (error) { - console.error("Error reading TicketData.json:", error); - } - - const ticketsClaimed = TicketData.tickets.filter((t) => t.claimedBy === viewingUserId); - const ticketsOpen = ticketsClaimed.filter((t) => t.open); - - let totalResponseTime = 0; - let responseCount = 0; - - ticketsClaimed.forEach((t) => { - if (t.claimedAt && t.createdAt) { - totalResponseTime += new Date(t.claimedAt) - new Date(t.createdAt); - responseCount++; - } - }); - - const avgMinutes = responseCount - ? Math.round(totalResponseTime / responseCount / 60000) - : 0; + const { ticketsClaimed, ticketsOpen, avgMinutes } = getStaffTicketStats(viewingUserId); const statsContainer = buildTicketStatsContainer( viewingUserId, diff --git a/Commands/General/translate.js b/Commands/General/translate.js index 4363f1b..161db24 100644 --- a/Commands/General/translate.js +++ b/Commands/General/translate.js @@ -7,9 +7,9 @@ const yaml = require("js-yaml"); const { translate } = require("@vitalets/google-translate-api"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const Command = require("../../Structures/Command.js"); diff --git a/Commands/Suggestions/suggest.js b/Commands/Suggestions/suggest.js index 7379099..148af80 100644 --- a/Commands/Suggestions/suggest.js +++ b/Commands/Suggestions/suggest.js @@ -3,9 +3,9 @@ const Discord = require("discord.js"); const yaml = require("js-yaml"); const path = require("path"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const Command = require("../../Structures/Command.js"); const DB = require("../../Structures/Database.js"); diff --git a/Commands/Suggestions/suggestadmin.js b/Commands/Suggestions/suggestadmin.js index 33b70ec..377cd91 100644 --- a/Commands/Suggestions/suggestadmin.js +++ b/Commands/Suggestions/suggestadmin.js @@ -2,9 +2,9 @@ const Discord = require("discord.js"); const fs = require("fs"); const yaml = require("js-yaml"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const msgconfig = require("../../Structures/ConfigStore").messages; +const cmdconfig = require("../../Structures/ConfigStore").commands; const SuggestionDB = require("../../Structures/Database.js"); const Command = require("../../Structures/Command.js"); diff --git a/Commands/Tickets/close.js b/Commands/Tickets/close.js index 9d0e8bd..3f3299d 100644 --- a/Commands/Tickets/close.js +++ b/Commands/Tickets/close.js @@ -8,9 +8,9 @@ const db = require("../../Structures/Database.js"); const TicketManager = require("../../Structures/TicketManager.js"); const Command = require("../../Structures/Command.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; function getDepartmentConfig(departmentKey) { return supportbot.Ticket?.DepartmentSystem?.Departments?.[departmentKey] || null; @@ -33,9 +33,10 @@ function buildContainer(title, lines = []) { container.addSeparatorComponents(new Discord.SeparatorBuilder().setDivider(true)); - if (lines.length > 0) { + const textContent = lines.filter(Boolean).join("\n").trim(); + if (textContent.length > 0) { container.addTextDisplayComponents( - new Discord.TextDisplayBuilder().setContent(lines.join("\n")), + new Discord.TextDisplayBuilder().setContent(textContent), ); } diff --git a/Commands/Tickets/manageticket.js b/Commands/Tickets/manageticket.js index 4be8c57..ec57184 100644 --- a/Commands/Tickets/manageticket.js +++ b/Commands/Tickets/manageticket.js @@ -12,9 +12,9 @@ const { TextInputStyle, } = require("discord.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const msgconfig = require("../../Structures/ConfigStore").messages; +const cmdconfig = require("../../Structures/ConfigStore").commands; const TicketManager = require("../../Structures/TicketManager.js"); const Command = require("../../Structures/Command.js"); diff --git a/Commands/Tickets/ticket.js b/Commands/Tickets/ticket.js index 8b0952f..69ae6b3 100644 --- a/Commands/Tickets/ticket.js +++ b/Commands/Tickets/ticket.js @@ -19,35 +19,16 @@ const yaml = require("js-yaml"); const db = require("../../Structures/Database.js"); -const panelconfig = yaml.load( - fs.readFileSync("./Configs/ticket-panel.yml", "utf8"), -); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8"), -); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const panelconfig = require("../../Structures/ConfigStore").ticketPanel; +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const Command = require("../../Structures/Command.js"); const TicketNumberID = require("../../Structures/TicketID.js"); async function isBlacklisted(userId) { - let blacklistData; - try { - blacklistData = JSON.parse( - fs.readFileSync("./Data/BlacklistedUsers.json", "utf8"), - ); - } catch (error) { - console.error("Error reading BlacklistedUsers.json:", error); - return false; - } - - if (!blacklistData || !Array.isArray(blacklistData.blacklistedUsers)) { - console.error("blacklistedUsers is not defined or is not an array"); - return false; - } - - return blacklistData.blacklistedUsers.includes(userId); + return typeof db.isUserBlacklisted === "function" ? db.isUserBlacklisted(userId) : false; } async function getClockedInUsers(guild) { diff --git a/Commands/Tickets/ticketpanel.js b/Commands/Tickets/ticketpanel.js index 1fab59f..cb82e7e 100644 --- a/Commands/Tickets/ticketpanel.js +++ b/Commands/Tickets/ticketpanel.js @@ -19,9 +19,9 @@ const { } = require("discord.js"); const yaml = require("js-yaml"); -const panelconfig = yaml.load(fs.readFileSync("./Configs/ticket-panel.yml", "utf8")); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const panelconfig = require("../../Structures/ConfigStore").ticketPanel; +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; const Command = require("../../Structures/Command.js"); const db = require("../../Structures/Database.js"); @@ -116,6 +116,7 @@ module.exports = new Command({ } const departmentOptions = Object.entries(departments) + .filter(([_, dept]) => dept && dept.Enabled !== false) .slice(0, 25) .map(([key, dept]) => ({ label: dept.Name || key, diff --git a/Commands/Utils/embed.js b/Commands/Utils/embed.js index 3024436..9ce36a4 100644 --- a/Commands/Utils/embed.js +++ b/Commands/Utils/embed.js @@ -16,9 +16,9 @@ const Command = require('../../Structures/Command.js'); const fs = require('fs'); const yaml = require('js-yaml'); -const supportbot = yaml.load(fs.readFileSync('./Configs/supportbot.yml', 'utf8')); -const cmdconfig = yaml.load(fs.readFileSync('./Configs/commands.yml', 'utf8')); -const msgconfig = yaml.load(fs.readFileSync('./Configs/messages.yml', 'utf8')); +const supportbot = require('../../Structures/ConfigStore').supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; function parseColor(color) { if (!color) return 0x5865F2; diff --git a/Commands/Utils/mod.js b/Commands/Utils/mod.js index 8cb7e56..cc3cb3f 100644 --- a/Commands/Utils/mod.js +++ b/Commands/Utils/mod.js @@ -1,9 +1,10 @@ const fs = require("fs"); const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } = require("discord.js"); const yaml = require("js-yaml"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const db = require("../../Structures/Database.js"); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; +const msgconfig = require("../../Structures/ConfigStore").messages; const Command = require("../../Structures/Command.js"); @@ -18,28 +19,28 @@ function chunkArray(arr, size) { module.exports = new Command({ name: cmdconfig.Mod.Command, - description: cmdconfig.Mod.Command, + description: cmdconfig.Mod.Description || "Moderation commands", type: ApplicationCommandType.ChatInput, options: [ { name: cmdconfig.Mod.TicketBlacklist.Command, - description: cmdconfig.Mod.TicketBlacklist.Description, + description: cmdconfig.Mod.TicketBlacklist.Description || "Ticket Blacklist Commands", type: ApplicationCommandOptionType.SubcommandGroup, options: [ { name: cmdconfig.Mod.TicketBlacklist.Add.Command, - description: msgconfig.Mod.TicketBlacklist.Add.Description || "Add a user to the ticket blacklist", + description: msgconfig.Mod.TicketBlacklist.Add.Description || "Add a user to the blacklist", type: ApplicationCommandOptionType.Subcommand, options: [ { name: 'user', - description: msgconfig.Mod.TicketBlacklist.Add.UserDescription || "User to blacklist", + description: 'The user to blacklist', type: ApplicationCommandOptionType.User, required: true }, { - name: 'reason', - description: msgconfig.Mod.TicketBlacklist.Add.ReasonDescription || "Reason for blacklisting", + name: 'reason', + description: 'Reason for blacklisting', type: ApplicationCommandOptionType.String, required: false } @@ -47,18 +48,18 @@ module.exports = new Command({ }, { name: cmdconfig.Mod.TicketBlacklist.Remove.Command, - description: msgconfig.Mod.TicketBlacklist.Remove.Description || "Remove a user from the ticket blacklist", + description: cmdconfig.Mod.TicketBlacklist.Remove.Description || "Remove a user from the blacklist", type: ApplicationCommandOptionType.Subcommand, options: [ { name: 'user', - description: msgconfig.Mod.TicketBlacklist.Remove.UserDescription || "User to remove from blacklist", + description: 'The user to remove from the blacklist', type: ApplicationCommandOptionType.User, required: true }, { name: 'reason', - description: msgconfig.Mod.TicketBlacklist.Remove.ReasonDescription || "Reason for removing from blacklist", + description: 'Reason for unblacklisting', type: ApplicationCommandOptionType.String, required: false } @@ -67,60 +68,32 @@ module.exports = new Command({ { name: "view", description: msgconfig.Mod.TicketBlacklist.View.Description || "View all blacklisted users", - type: ApplicationCommandOptionType.Subcommand, + type: ApplicationCommandOptionType.Subcommand } ] } ], - permissions: cmdconfig.Mod.Permission, async run(interaction) { - const { getRole } = interaction.client; - - let SupportStaff = await getRole(supportbot.Roles.StaffMember.Staff, interaction.guild); - let Admin = await getRole(supportbot.Roles.StaffMember.Admin, interaction.guild); - let Moderator = await getRole(supportbot.Roles.StaffMember.Moderator, interaction.guild); - - if (!SupportStaff || !Admin || !Moderator) { - const missingRolesEmbed = new EmbedBuilder() - .setDescription(msgconfig.Error.InvalidChannel || "Required roles are missing!") - .setColor(supportbot.Embed.Colours.Warn); - return interaction.reply({ embeds: [missingRolesEmbed] }); - } - - const NoPerms = new EmbedBuilder() - .setDescription(msgconfig.Error.IncorrectPerms || "You do not have the correct permissions!") - .setColor(supportbot.Embed.Colours.Warn); - - if (!interaction.member.roles.cache.has(Admin.id) && !interaction.member.roles.cache.has(Moderator.id) && (!supportbot.Mod.AllowSupportStaff || !interaction.member.roles.cache.has(SupportStaff.id))) { - return interaction.reply({ embeds: [NoPerms] }); - } - - const subcommand = interaction.options.getSubcommand(false); + const subcommandGroup = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); const user = interaction.options.getUser('user'); const reason = interaction.options.getString('reason') || "No reason provided"; try { - let blacklistedUsers; - try { - blacklistedUsers = JSON.parse(fs.readFileSync("./Data/BlacklistedUsers.json", "utf8")).blacklistedUsers; - if (!Array.isArray(blacklistedUsers)) { - blacklistedUsers = []; - } - } catch (error) { - blacklistedUsers = []; - } + const blacklistedUsers = typeof db.getBlacklistedUsers === "function" ? db.getBlacklistedUsers() : []; if (subcommand === cmdconfig.Mod.TicketBlacklist.Add.Command) { - if (blacklistedUsers.includes(user.id)) { + if (db.isUserBlacklisted && db.isUserBlacklisted(user.id)) { const alreadyBlacklistedEmbed = new EmbedBuilder() .setDescription(msgconfig.Mod.TicketBlacklist.Add.AlreadyBlacklisted.replace("{userTag}", user.tag) || ":x: User is already blacklisted.") .setColor(supportbot.Embed.Colours.Warn); return interaction.reply({ embeds: [alreadyBlacklistedEmbed], flags: MessageFlags.Ephemeral }); } - blacklistedUsers.push(user.id); - fs.writeFileSync("./Data/BlacklistedUsers.json", JSON.stringify({ "blacklistedUsers": blacklistedUsers }, null, 4)); + if (typeof db.addBlacklistedUser === "function") { + db.addBlacklistedUser(user.id, reason, interaction.user.tag); + } const successEmbed = new EmbedBuilder() .setDescription(msgconfig.Mod.TicketBlacklist.Add.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been blacklisted.") @@ -133,7 +106,7 @@ module.exports = new Command({ .setColor(msgconfig.TicketBlacklistLog.Colour || supportbot.Embed.Colours.Success) .setFooter({ text: supportbot.Embed.Footer, iconURL: interaction.user.displayAvatarURL() }) .setDescription(`> **User:** ${user.tag} (\`${user.id}\`)\n> **Actioned by:** <@${interaction.user.id}>`) - .addFields({ name: "Action", value: "\`\`\`Removed from blacklist\`\`\`", inline: false }) + .addFields({ name: "Action", value: "\`\`\`Added to blacklist\`\`\`", inline: false }) .addFields({ name: "Reason", value: `\`\`\`${reason}\`\`\``, inline: false }); blacklistChannel.send({ embeds: [blacklistLogEmbed] }); @@ -145,15 +118,16 @@ module.exports = new Command({ }); } else if (subcommand === cmdconfig.Mod.TicketBlacklist.Remove.Command) { - if (!blacklistedUsers.includes(user.id)) { + if (db.isUserBlacklisted && !db.isUserBlacklisted(user.id)) { const notBlacklistedEmbed = new EmbedBuilder() .setDescription(msgconfig.Mod.TicketBlacklist.Remove.NotBlacklisted.replace("{userTag}", user.tag) || ":x: User is not blacklisted.") .setColor(supportbot.Embed.Colours.Warn); return interaction.reply({ embeds: [notBlacklistedEmbed], flags: MessageFlags.Ephemeral }); } - blacklistedUsers = blacklistedUsers.filter(id => id !== user.id); - fs.writeFileSync("./Data/BlacklistedUsers.json", JSON.stringify({ "blacklistedUsers": blacklistedUsers }, null, 4)); + if (typeof db.removeBlacklistedUser === "function") { + db.removeBlacklistedUser(user.id); + } const removedEmbed = new EmbedBuilder() .setDescription(msgconfig.Mod.TicketBlacklist.Remove.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been removed from the blacklist.") diff --git a/Commands/Utils/settings.js b/Commands/Utils/settings.js index 6131921..acd4902 100644 --- a/Commands/Utils/settings.js +++ b/Commands/Utils/settings.js @@ -1,35 +1,18 @@ -const { Command } = require('../../Structures/Addon.js'); +const Command = require("../../Structures/Command.js"); const Discord = require("discord.js"); -const fs = require("fs"); -const yaml = require("js-yaml"); const db = require("../../Structures/Database.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../../Structures/ConfigStore").supportbot; +const cmdconfig = require("../../Structures/ConfigStore").commands; async function loadSettings() { - return new Promise((resolve, reject) => { - db.get("SELECT data FROM settings WHERE id = 1", (err, row) => { - if (err) return reject(err); - if (!row) return resolve({}); - try { - resolve(JSON.parse(row.data)); - } catch (e) { - resolve({}); - } - }); - }); + return typeof db.getSettings === "function" ? db.getSettings() : {}; } async function saveSettings(settings) { - const data = JSON.stringify(settings); - return new Promise((resolve, reject) => { - db.run( - "INSERT INTO settings (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data", - [data], - (err) => (err ? reject(err) : resolve()) - ); - }); + if (typeof db.saveSettings === "function") { + db.saveSettings(settings); + } } module.exports = new Command({ diff --git a/Configs/api.yml b/Configs/api.yml index af83968..3aa2a67 100644 --- a/Configs/api.yml +++ b/Configs/api.yml @@ -1,8 +1,63 @@ +# ___ _ ___ _ +# / __> _ _ ___ ___ ___ _ _ _| |_ | . > ___ _| |_ +# \__ \| | || . \| . \/ . \| '_> | | | . \/ . \ | | +# <___/`___|| _/| _/\___/|_| |_| |___/\___/ |_| +# |_| |_| +# +# SupportBot created by Emerald Services +# Installed with MIT License +# +# Discord Support: https://discord.gg/emeraldsrv +# Official Website & Marketplace: https://emeraldsrv.com +# +# Messages Configuration (api.yml) + + # ------------------------------- # API Server Configuration # ------------------------------- API: Enabled: true - Port: 3000 # PORT NUMBER FOR YOUR BOT - SecretKey: "PUT_RANDOM_STRING_HERE" # DO NOT SHARE THIS KEY WITH ANYONE \ No newline at end of file + Port: 3000 + + SecretKey: "SECRET_KEY" + + # Set to 1 when the dashboard is behind nginx, Cloudflare, Pterodactyl proxy, etc. + # so http/https is detected from X-Forwarded-Proto. + + TrustProxy: false + + EmeraldAPIKey: "EMERALDSRV_API_KEY" + # This is required for the one-click addon installer. Get API key from https://emeraldsrv.dev/ + + OAuth: + Enabled: true + ClientId: "CLIENT_ID" + ClientSecret: "CLIENT_SECRET" + # Get Client ID and Client Secret from https://discord.com/developers/applications + + # Canonical callback URL (host + path). Protocol (http/https) can follow the + # browser when UseRequestOrigin is true (default). + + RedirectUri: "http://localhost:3000/api/auth/discord/callback" + + # Optional: register extra callback URLs in Discord and list them here. + + # RedirectUris: + # - "https://bot.example.com/api/auth/discord/callback" + # UseRequestOrigin: true + # CookieSecure: false + + OwnerUserIds: + - "OWNER_USER_ID" + + AllowedUserIds: [] + +# Dashboard Features + + LiveChat: + Mode: "webhook" + # "webhook" - Webhook to send messages with staff member's avatar and Discord username + # "bot" - Send messages as Bot account with discord username prefix. + # "bot_noprefix" - Send messages as Bot account without discord username prefix. \ No newline at end of file diff --git a/Configs/messages.yml b/Configs/messages.yml index edee95a..5ee6d6c 100644 --- a/Configs/messages.yml +++ b/Configs/messages.yml @@ -21,7 +21,7 @@ Info: Title: "About Us" Description: "SupportBot is an open-sourced support system for discord. It's completely free to use with high amounts of customization to match your own brand." Button: "Visit Site" - URL: "https://docs.emeraldsrv.com" + URL: "https://emeraldsrv.dev" Colour: "#45b463" # ---------------------------- @@ -30,15 +30,15 @@ Info: Welcome: Enabled: true - LayoutStyle: "modern" + LayoutStyle: "classic" # Available Layout Styles # "modern" - For the new Discord v2 components # "classic" - Use the classic Discord look. Embed: Colour: "#45b463" - Title: "Welcome to ServerName" - Message: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#1202667199853232129>" + Title: "Welcome!" + Message: ":smile: %joined_user% has just landed!" Icon: Mode: "USER" # USER | BOT | CUSTOM CustomURL: "https://example.com/custom.png" @@ -48,23 +48,15 @@ Welcome: Buttons: Enabled: true List: - - label: "BuiltByBit" + - label: "Website" style: "Link" - url: "https://your-website.com/how-to-play-java" + url: "https://emeraldsrv.dev" emoji: "⌨️" - - label: "GitHub" - style: "Link" - url: "https://your-website.com/how-to-play-bedrock" - emoji: "👾" - - label: "Tebex" - style: "Link" - url: "https://your-store.tebex.io/" - emoji: "🛒" Modern: Colour: "#45b463" Title: "**Welcome to ServerName**" - Body: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#1202667199853232129>" + Body: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#CHANNEL_ID>" Icon: Mode: "BOT" # USER | BOT | CUSTOM CustomURL: "https://example.com/custom-icon.png" diff --git a/Configs/supportbot-ai.yml b/Configs/supportbot-ai.yml index da9534a..7005f1a 100644 --- a/Configs/supportbot-ai.yml +++ b/Configs/supportbot-ai.yml @@ -8,8 +8,8 @@ Enabled: true General: - Provider: "claude" - Model: "claude-sonnet-4-6" + Provider: "openai" + Model: "MODEL" Model_API_Key: "MODEL_API_KEY" # Providers Available: # "openai" - Use OpenAI's GPT models. Example: "gpt-5.4" "gpt-5-mini" @@ -17,15 +17,15 @@ General: # "claude" - Use Anthropic's Claude models. Example: "claude-sonnet-4-6" Tokens: 1200 - + Temperature: 0.4 Name: "SupportBot AI" - PastebinAPI_Key: "PASTEBIN_API_KEY" + PastebinAPI_Key: "YOUR_PASTEBIN_API_KEY" PastebinAPI_URL: "https://pastebin.com/api/api_post.php" Style: - MessageType: "text" + MessageType: "components_v2" # Available Styles for AI: # "embed" - AI responses will be sent as Discord embeds. # "text" - AI responses will be sent as plain messages. @@ -35,7 +35,7 @@ Features: SuggestTickets: true # Should SupportBot AI suggest creating a ticket if the user needs more help? Channels: - AIChannel: "1469028791312715902" + AIChannel: "CHANNEL_ID" Embed: Color: "#45b463" @@ -59,12 +59,23 @@ Memory: CustomInstructions: - "You are SupportBot AI made by Emerald Services" - - "You are built with DiscordJS/NodeJS and currently running on dev build 26.0 build 2" - - "You're current build can be found here https://github.com/Emerald-Services/SupportBot/tree/26.0" - - "You're main build can be found here https://builtbybit.com/resources/supportbot-free-advanced-ticket-bot.8940/ and Polymart at https://polymart.org/product/518/supportbot-1-discord-ticket-bot which is version 8.4" - - "dev build 26.0 build 2 includes all-new AI features for SupportBot such as memory system that allows you to remember facts and summaries of conversations, which can be used to provide better responses." - - "SupportBot AI also have a new tickets AI Mode which is enabled in supportbot.ymll, which allows you to assist users in support tickets and provide better responses based on the context of the ticket." - - "Your main purpose is to assist users in the support server and provide helpful responses to their questions and issues. Always be accurate and if you do not have answer then simply ask the person to create a ticket if they need more help." + - "You are built with DiscordJS/NodeJS and currently running on version 26.2" + - "You're current build can be found here + https://github.com/Emerald-Services/SupportBot" + - "You're main build can be found here + https://builtbybit.com/resources/supportbot-free-advanced-ticket-bot.8940/ + and Polymart at + https://polymart.org/product/518/supportbot-1-discord-ticket-bot" + - "SupportBot includes all-new AI features such as a memory system that allows + you to remember facts and summaries of conversations, which can be used to + provide better responses." + - "SupportBot AI also has a tickets AI Mode which is enabled in + supportbot.yml, which allows you to assist users in support tickets and + provide better responses based on the context of the ticket." + - "Your main purpose is to assist users in the support server and provide + helpful responses to their questions and issues. Always be accurate and if + you do not have answer then simply ask the person to create a ticket if they + need more help." #- "Add custom instructions here" Messages: @@ -73,4 +84,4 @@ Messages: AIDisabled: "AI is currently disabled." MessageTooLong: "My response was too long, so I uploaded it here: {pastebin}" SuggestTicketText: "-# Need more help? React with 🎫 to open a ticket." - SuggestTicketReaction : "🎫" + SuggestTicketReaction: "🎫" diff --git a/Configs/supportbot.yml b/Configs/supportbot.yml index 72117b5..e57debe 100644 --- a/Configs/supportbot.yml +++ b/Configs/supportbot.yml @@ -12,7 +12,7 @@ # # SupportBot Configuration (supportbot.yml) -SupportBot_Version: "v26.1.1" +SupportBot_Version: "v26.2" # ------------------------- # General @@ -20,20 +20,39 @@ SupportBot_Version: "v26.1.1" General: Name: "SupportBot" - Token: "BOT_TOKEN" - + Token: "YOUR_BOT_TOKEN_HERE" Addons: Enabled: true - + GuildId: "YOUR_GUILD_ID_HERE" + # ------------------------- +# Database Configuration +# ------------------------- + +Database: + # Driver options: "mysql" (works for MariaDB & MySQL) or "sqlite" + Driver: "mysql" + + # Connection Settings for MariaDB / MySQL (used when Driver is "mysql" or "mariadb") + MySQL: + Host: "localhost" + Port: 3306 + User: "root" + Password: "password" + Database: "supportbot" + ConnectionLimit: 10 + + # Connection Settings for SQLite (used when Driver is "sqlite") + SQLite: + File: "./Data/supportbot.db" + # Activity # ------------------------- Activity: - Status: "SupportBot v8" - Type: "Competing" + Status: "SupportBot" + Type: "Watching" StreamingURL: "https://www.twitch.tv/YOUR_TWITCH_NAME" - # Available Types: # Playing # Watching @@ -43,9 +62,9 @@ Activity: # You can also update the activity via Discord using: /settings -# ------------------------- -# Embeds -# ------------------------- + # ------------------------- + # Embeds + # ------------------------- Embed: Colours: @@ -55,12 +74,11 @@ Embed: Warn: "#fca117" Footer: "SupportBot | Developed by Emerald Development" - # Use 0x to change the colour of the embeds. -# ------------------------- -# Roles -# ------------------------- + # ------------------------- + # Roles + # ------------------------- Roles: AutoRole: @@ -73,20 +91,19 @@ Roles: Moderator: "ROLE_ID" Mod: - AllowSupportStaff: false - + AllowSupportStaff: true # Allow Support Staff to use the /mod command. (More in detail customisation in commands.yml) -# ---------------------------- -# System Messages -# ---------------------------- + # ---------------------------- + # System Messages + # ---------------------------- -Welcome: - Enabled: true +Welcome: + Enabled: false Channel: "CHANNEL_ID" -Leave: - Enabled: true +Leave: + Enabled: false Channel: "CHANNEL_ID" # ---------------------------- @@ -110,7 +127,7 @@ Suggestions: # If true this will allow the user to vote for a suggestion they created ShowUsers: true - + Threads: Enabled: true Title: "Suggestion-releated Thread" @@ -121,7 +138,6 @@ Suggestions: Downvote: "2" RemoveVote: "4" RemoveVote_Title: "Remove Vote" - # Available Button Styles # 1 - Blurple # 2 - Grey @@ -129,21 +145,21 @@ Suggestions: # 4 - Red Tags: - Created: "TAG_ID" - Considering: "TAG_ID" - Solved: "TAG_ID" - Denied: "TAG_ID" + Created: "TAG_ID" + Considering: "TAG_ID" + Solved: "TAG_ID" + Denied: "TAG_ID" # ------------------------- # Tickets # ------------------------- VoiceTickets: - Name: "%username%'s VC" + Name: "%username%'s VC" Category: "CATEGORY_ID" Ticket: - TicketType: "channels" + TicketType: "channels" # Available TicketTypes: # channels - Tickets will be created as channels in the TicketChannelsCategory @@ -154,14 +170,14 @@ Ticket: DMOnRemove: true # DM the user when they are removed from the ticket DMTranscript: true # DM the user a transcript of the ticket when it is closed - TicketHome: "1469028381638000725" + TicketHome: "CHANNEL_ID" # What is TicketHome? # - Channel where the Ticket creation panel is sent. # - Channel where ticket threads are made. (only if ticket threads is enabled above.) # For TicketType: channels - TicketChannelsCategory: "CATEGORY_ID" + TicketChannelsCategory: "CATEGORY_ID" TicketChannelsCategory2: "CATEGORY_ID" # This will be used if the first category has reached the max channel limit. TicketArchiveCategory: "CATEGORY_ID" # --- @@ -184,14 +200,15 @@ Ticket: StaffOnly: true # Set to false is anyone is able to close a ticket. - Invites: # Invites is for ticket threads. + Invites: + # Invites is for ticket threads. StaffOnly: true ClaimTickets: Enabled: false - Channel: "CHANNEL_ID" + Channel: "CHANNEL_ID" # Channel where the ticket assignments are sent for staff to claim. - + ClaimAcceptTime: 120000 # How long the assigned user has to accept the ticket before it gets re-assigned to someone else. # 120000 = 2 Minutes @@ -203,7 +220,6 @@ Ticket: ButtonTitle: "Claim Ticket" ButtonEmoji: "🎫" Button: "1" - # Available Button Styles # 1 - Blurple # 2 - Grey @@ -211,12 +227,13 @@ Ticket: # 4 - Red ReviewSystem: - Enabled: false + Enabled: false Channel: "CHANNEL_ID" UseModal: true - Questions: # Default questions asked when a ticket is created, these are used if departments are disabled. - Enabled: false + Questions: + # Default questions asked when a ticket is created, these are used if departments are disabled. + Enabled: false List: - "Please provide your account email?" - "What is the nature of your issue?" @@ -232,12 +249,12 @@ Ticket: ThreadName: "Chat with SupportBot AI" WelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue." -# ------------------------- -# Ticket Departments -# ------------------------- + # ------------------------- + # Ticket Departments + # ------------------------- DepartmentSystem: - Enabled: true + Enabled: false Placeholder: "Choose a ticket department" DefaultDepartment: "general" @@ -339,14 +356,14 @@ Buttons: # 3 - Green # 4 - Red -# ------------------------- -# Select Menus -# ------------------------- + # ------------------------- + # Select Menus + # ------------------------- SelectMenus: Tickets: PanelEmoji: "⚙️" - + CloseEmoji: "🗑️" ArchiveEmoji: "♻️" LockEmoji: "🔐" diff --git a/Configs/ticket-panel.yml b/Configs/ticket-panel.yml index 9817200..4de8d43 100644 --- a/Configs/ticket-panel.yml +++ b/Configs/ticket-panel.yml @@ -20,11 +20,12 @@ Panel: true Style: - Title: "## SupportBot Ticket Creation" - Description: "Click on the button to file a `Ticket` and our team will try their best to assist you in the best way we can!" - Image: "https://i.imgur.com/dejQvkg.png" + Title: "## 📬 Open Ticket" + Description: "Tap the button to open a support ticket, Our team will respond as + soon as possible." + Image: "https://cdn.1tzemerald.space/Emerald%20Services/SBPoster.png" Color: "#45b463" - Layout: "4" + Layout: "3" # Available Layout Styles # "1" - Style 1 - Title & Text, Button at the top -- Image at the bottom @@ -33,8 +34,8 @@ Style: # "4" - Style 4 - Classic Embed (Title & Text, Image on right, Button at bottom) Button: - Text: "Create Ticket" - Emoji: "🎫" + Text: "Open" + Emoji: "🛟" Color: "2" CustomId: "createticket" @@ -45,7 +46,7 @@ Button: # 4 - Green DepartmentMenu: - CustomId: "ticketdepartmentselect" + CustomId: "ticketdepartmentselect" Placeholder: "Choose a ticket department" DefaultDescription: "Open a support ticket" DefaultEmoji: "🎫" @@ -58,7 +59,8 @@ Settings: BottomDivider: false Messages: - NotConfigured: "Ticket Panel is not set up. Please configure it in `/Configs/ticket-panel.yml`." + NotConfigured: "Ticket Panel is not set up. Please configure it in + `/Configs/ticket-panel.yml`." InvalidChannel: "TicketHome channel not found. Please check your config file." PanelSent: "Ticket panel message has been sent!" PanelSentWithDepartments: "Ticket panel message with department selection has been sent!" diff --git a/Configs/transcript-template.yml b/Configs/transcript-template.yml new file mode 100644 index 0000000..824920f --- /dev/null +++ b/Configs/transcript-template.yml @@ -0,0 +1,33 @@ +Theme: + Background: '#0e0e0e' + Panel: '#1d1d1d' + PanelSecondary: '#222222' + PanelTertiary: '#1d2938' + Border: '#2e2e2e' + Text: '#e6edf3' + Muted: '#606060' + Accent: '#396ff8' + AccentSoft: rgba(34, 197, 94, 0.12) + AccentBorder: rgba(34, 197, 94, 0.25) + AccentText: '#8df0ad' + Link: '#396ff8' + AttachmentLink: '#396ff8' + CodeBackground: '#0e0e0e' +Typography: + FontFamily: Poppins +Header: + Title: Ticket Transcript + Subtitle: Exported conversation log for support ticket **{{ticketName}}** + Badge: Closed Transcript + ShowMetaCards: true +Meta: + ChannelLabel: Ticket Name + TicketIdLabel: Ticket ID + MessageCountLabel: Message Count + CloseReasonLabel: Close Reason + DefaultCloseReason: No Reason Provided. +Messages: + SectionTitle: Messages + EmptyState: No messages were found in this ticket. +Advanced: + CustomCss: '' diff --git a/Data/BlacklistedUsers.json b/Data/BlacklistedUsers.json deleted file mode 100644 index 257b226..0000000 --- a/Data/BlacklistedUsers.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "blacklistedUsers": [] -} \ No newline at end of file diff --git a/Data/SupportBot-AI.json b/Data/SupportBot-AI.json deleted file mode 100644 index 01ad2f9..0000000 --- a/Data/SupportBot-AI.json +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/Events/Models/claude.js b/Events/Models/claude.js index 2b38198..af85e12 100644 --- a/Events/Models/claude.js +++ b/Events/Models/claude.js @@ -5,9 +5,7 @@ const fs = require("fs"); const yaml = require("js-yaml"); const axios = require("axios"); -const supportbotai = yaml.load( - fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), -); +const supportbotai = require("../../Structures/ConfigStore").supportbotAi; function extractTextFromMessage(message) { if (!message) return ""; diff --git a/Events/Models/groq.js b/Events/Models/groq.js index 8eab0ca..f44d126 100644 --- a/Events/Models/groq.js +++ b/Events/Models/groq.js @@ -5,9 +5,7 @@ const fs = require("fs"); const yaml = require("js-yaml"); const OpenAI = require("openai"); -const supportbotai = yaml.load( - fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), -); +const supportbotai = require("../../Structures/ConfigStore").supportbotAi; const client = new OpenAI({ apiKey: supportbotai.General.Model_API_Key, diff --git a/Events/Models/openai.js b/Events/Models/openai.js index 0df490f..8b29116 100644 --- a/Events/Models/openai.js +++ b/Events/Models/openai.js @@ -5,9 +5,7 @@ const fs = require("fs"); const yaml = require("js-yaml"); const OpenAI = require("openai"); -const supportbotai = yaml.load( - fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), -); +const supportbotai = require("../../Structures/ConfigStore").supportbotAi; const client = new OpenAI({ apiKey: supportbotai.General.Model_API_Key, diff --git a/Events/guildCreate.js b/Events/guildCreate.js index 16bd038..3365140 100644 --- a/Events/guildCreate.js +++ b/Events/guildCreate.js @@ -1,9 +1,31 @@ const Event = require("../Structures/Event.js"); -module.exports = new Event("guildCreate", async (client) => { - console.log( - `\u001b[31m`, - `${client.user.username} is not in the correct server set in your config. Please leave all other servers and restart the bot.` +const { + getConfiguredGuildId, + syncGuildHealth, + logGuildWarning, +} = require("../Structures/GuildManager.js"); + +module.exports = new Event("guildCreate", async (client, guild) => { + const configuredId = getConfiguredGuildId(); + + if (!configuredId) { + logGuildWarning( + client, + `Joined "${guild.name}" (${guild.id}). Set General.GuildId in config to lock the bot to one server.`, + ); + syncGuildHealth(client); + return; + } + + if (guild.id === configuredId) { + syncGuildHealth(client); + return; + } + + logGuildWarning( + client, + `Joined unauthorized server "${guild.name}" (${guild.id}). Leave it from the dashboard or remove the invite.`, ); - console.log(`\u001b[31m`, `${client.user.username} will now exit.`); - return process.exit(1); + + syncGuildHealth(client); }); diff --git a/Events/guildDelete.js b/Events/guildDelete.js index d3cfbd2..8fde2a7 100644 --- a/Events/guildDelete.js +++ b/Events/guildDelete.js @@ -1,10 +1,23 @@ const Event = require("../Structures/Event.js"); +const { + getConfiguredGuildId, + syncGuildHealth, + logGuildWarning, +} = require("../Structures/GuildManager.js"); -module.exports = new Event("guildDelete", async (client) => { - console.log( - `\u001b[31m`, - `${client.user.username} is not in the correct server set in your config. Please join the server and restart the bot.` - ); - console.log(`\u001b[31m`, `${client.user.username} will now exit.`); - return process.exit(1); +module.exports = new Event("guildDelete", async (client, guild) => { + const configuredId = getConfiguredGuildId(); + + if (configuredId && guild.id === configuredId) { + logGuildWarning( + client, + `Left the configured server "${guild.name}" (${guild.id}). Re-invite the bot or update General.GuildId.`, + ); + } else if (!configuredId) { + logGuildWarning(client, `Left server "${guild.name}" (${guild.id}).`); + } else { + logGuildWarning(client, `Left server "${guild.name}" (${guild.id}).`); + } + + syncGuildHealth(client); }); diff --git a/Events/guildMemberAdd.js b/Events/guildMemberAdd.js index d37fefa..9155eef 100644 --- a/Events/guildMemberAdd.js +++ b/Events/guildMemberAdd.js @@ -10,16 +10,10 @@ const { ThumbnailBuilder, MediaGalleryBuilder } = require("discord.js"); -const fs = require("fs"); -const yaml = require("js-yaml"); const Event = require("../Structures/Event.js"); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8") -); -const msgconfig = yaml.load( - fs.readFileSync("./Configs/messages.yml", "utf8") -); +const supportbot = require("../Structures/ConfigStore").supportbot; +const msgconfig = require("../Structures/ConfigStore").messages; function getWelcomeThumbnail(client, member, iconCfg) { switch ((iconCfg?.Mode || "").toUpperCase()) { diff --git a/Events/guildMemberRemove.js b/Events/guildMemberRemove.js index f4d1bb2..556a966 100644 --- a/Events/guildMemberRemove.js +++ b/Events/guildMemberRemove.js @@ -3,8 +3,8 @@ const fs = require("fs"); const yaml = require("js-yaml"); const Event = require("../Structures/Event.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; +const msgconfig = require("../Structures/ConfigStore").messages; module.exports = new Event("guildMemberRemove", async (client, member) => { diff --git a/Events/interactionCreate.js b/Events/interactionCreate.js index 54b32d2..2b26d99 100644 --- a/Events/interactionCreate.js +++ b/Events/interactionCreate.js @@ -1,12 +1,8 @@ -const fs = require("fs"); const Discord = require("discord.js"); -const yaml = require("js-yaml"); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8"), -); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; +const cmdconfig = require("../Structures/ConfigStore").commands; +const msgconfig = require("../Structures/ConfigStore").messages; const Event = require("../Structures/Event.js"); const db = require("../Structures/Database.js"); @@ -69,6 +65,7 @@ module.exports = new Event("interactionCreate", async (client, interaction) => { "\u001b[37;1m", `${interaction.user.username} has executed ${command.name}`, ); + await command.run(interaction); } } catch (error) { @@ -143,6 +140,13 @@ module.exports = new Event("interactionCreate", async (client, interaction) => { if (interaction.isStringSelectMenu()) { if (interaction.customId === "ticketdepartmentselect") { const selectedDepartment = interaction.values[0]; + const deptConfig = supportbot.Ticket?.DepartmentSystem?.Departments?.[selectedDepartment]; + if (deptConfig && deptConfig.Enabled === false) { + return interaction.reply({ + content: "Ticket creation for this department is currently disabled.", + flags: Discord.MessageFlags.Ephemeral, + }); + } if (supportbot.Ticket.TicketReason) { const modal = new Discord.ModalBuilder() @@ -683,7 +687,15 @@ module.exports = new Event("interactionCreate", async (client, interaction) => { }); } - if (interaction.customId === "createticket") { + if (interaction.customId === "createticket" || interaction.customId === "openticket") { + const panelDisabled = panelconfig.Panel === false || supportbot.Ticket?.Enabled === false; + if (panelDisabled) { + return interaction.reply({ + content: "Ticket creation is currently disabled.", + flags: Discord.MessageFlags.Ephemeral, + }); + } + if (supportbot.Ticket.TicketReason) { const modal = new Discord.ModalBuilder() .setCustomId("ticketReasonModal") @@ -855,69 +867,52 @@ module.exports = new Event("interactionCreate", async (client, interaction) => { interaction.customId === "downvote" || interaction.customId === "removevote" ) { - const suggestion = suggestions[interaction.message.id]; - if (!suggestion) - return interaction.reply({ - content: "Suggestion not found.", - ephemeral: true, - }); + const suggestion = (typeof db.getSuggestion === "function" ? db.getSuggestion(interaction.message.id) : null) || { + id: interaction.message.id, + author_id: "", + text: "", + status: "Created", + upvotes: [], + downvotes: [], + }; const userId = interaction.user.id; - const hasUpvoted = suggestion.upvotes.includes(userId); - const hasDownvoted = suggestion.downvotes.includes(userId); - if (interaction.customId === "upvote") { - if (hasUpvoted) { - suggestion.upvotes = suggestion.upvotes.filter((id) => id !== userId); - } else { - suggestion.upvotes.push(userId); - if (hasDownvoted) { - suggestion.downvotes = suggestion.downvotes.filter( - (id) => id !== userId, - ); - } - } - } else if (interaction.customId === "downvote") { - if (hasDownvoted) { - suggestion.downvotes = suggestion.downvotes.filter( - (id) => id !== userId, - ); - } else { - suggestion.downvotes.push(userId); - if (hasUpvoted) { - suggestion.upvotes = suggestion.upvotes.filter( - (id) => id !== userId, - ); - } - } - } else if (interaction.customId === "removevote") { - suggestion.upvotes = suggestion.upvotes.filter((id) => id !== userId); - suggestion.downvotes = suggestion.downvotes.filter( - (id) => id !== userId, - ); + if (supportbot.Suggestions?.OwnSuggestion === false && suggestion.author_id === userId) { + return interaction.reply({ + content: "You cannot vote on your own suggestion.", + flags: Discord.MessageFlags.Ephemeral, + }); } - fs.writeFileSync( - "./Data/SuggestionData.json", - JSON.stringify(suggestions, null, 2), - ); + const voteResult = typeof db.voteSuggestion === "function" + ? db.voteSuggestion(interaction.message.id, userId, interaction.customId) + : { upvoteCount: 0, downvoteCount: 0 }; - const upvoteCount = suggestion.upvotes.length; - const downvoteCount = suggestion.downvotes.length; + const oldEmbed = interaction.message.embeds?.[0]; + if (!oldEmbed) { + return interaction.reply({ + content: "Suggestion embed not found.", + flags: Discord.MessageFlags.Ephemeral, + }); + } - const updatedEmbed = new Discord.EmbedBuilder( - interaction.message.embeds[0].data, - ).setFields([ - { name: "Suggestion", value: suggestion.suggestion, inline: true }, - { name: "From", value: `<@${suggestion.author}>` }, + const suggestionText = suggestion.text || oldEmbed.fields?.find((f) => f.name === "Suggestion")?.value || ""; + const authorText = suggestion.author_id + ? `<@${suggestion.author_id}>` + : (oldEmbed.fields?.find((f) => f.name === "From")?.value || `<@${userId}>`); + + const updatedEmbed = Discord.EmbedBuilder.from(oldEmbed).setFields([ + { name: "Suggestion", value: suggestionText, inline: true }, + { name: "From", value: authorText }, { name: `${supportbot.Suggestions.UpvoteEmoji} ${supportbot.Suggestions.UpvoteTitle}`, - value: `${upvoteCount}`, + value: `${voteResult.upvoteCount}`, inline: true, }, { name: `${supportbot.Suggestions.DownvoteEmoji} ${supportbot.Suggestions.DownvoteTitle}`, - value: `${downvoteCount}`, + value: `${voteResult.downvoteCount}`, inline: true, }, ]); diff --git a/Events/messageCreate.js b/Events/messageCreate.js index 35506b1..8e5a7e7 100644 --- a/Events/messageCreate.js +++ b/Events/messageCreate.js @@ -1,14 +1,8 @@ -const fs = require("fs"); const Discord = require("discord.js"); -const yaml = require("js-yaml"); const axios = require("axios"); -const supportbotai = yaml.load( - fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), -); -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8"), -); +const supportbotai = require("../Structures/ConfigStore").supportbotAi; +const supportbot = require("../Structures/ConfigStore").supportbot; const Event = require("../Structures/Event.js"); const AIDatabase = require("../Structures/AIDatabase.js"); @@ -398,6 +392,92 @@ function rememberUserFacts(message) { } module.exports = new Event("messageCreate", async (client, message) => { + if (message.guild && typeof db.getTicket === "function") { + const activeTicket = db.getTicket(message.channel.id); + if (activeTicket && global.apiServer) { + global.apiServer.broadcast("ticket_message", { + ticketId: message.channel.id, + message: { + id: message.id, + author: { + id: message.author.id, + username: message.author.username, + globalName: message.author.globalName || message.author.username, + avatar: message.author.displayAvatarURL({ extension: "png", size: 64 }), + bot: message.author.bot + }, + content: (() => { + if (message.content && message.content.trim()) return message.content; + if (Array.isArray(message.components) && message.components.length > 0) { + const texts = []; + for (const comp of message.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 message.content || ""; + })(), + containerColor: (() => { + if (Array.isArray(message.components)) { + for (const comp of message.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: (message.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: (message.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((message.attachments || []).values()).map(a => ({ + id: a.id, + name: a.name, + url: a.url, + contentType: a.contentType + })), + createdAt: message.createdTimestamp + } + }); + } + } + if (message.author.bot || !message.guild) return; let shouldRespond = diff --git a/Events/messageDelete.js b/Events/messageDelete.js index 5490f9c..d2d2b5d 100644 --- a/Events/messageDelete.js +++ b/Events/messageDelete.js @@ -3,8 +3,8 @@ const fs = require("fs"); const yaml = require("js-yaml"); const Event = require("../Structures/Event.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; +const msgconfig = require("../Structures/ConfigStore").messages; module.exports = new Event("messageDelete", async (client, message) => { if (!message.guild) return; diff --git a/Events/messageReactionAdd.js b/Events/messageReactionAdd.js index 25e6508..fe9614e 100644 --- a/Events/messageReactionAdd.js +++ b/Events/messageReactionAdd.js @@ -1,13 +1,6 @@ -const fs = require("fs"); -const yaml = require("js-yaml"); - -const supportbot = yaml.load( - fs.readFileSync("./Configs/supportbot.yml", "utf8"), -); -const supportbotai = yaml.load( - fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), -); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; +const supportbotai = require("../Structures/ConfigStore").supportbotAi; +const cmdconfig = require("../Structures/ConfigStore").commands; const Event = require("../Structures/Event.js"); diff --git a/Events/messageUpdate.js b/Events/messageUpdate.js index 4fac6a0..ff963ee 100644 --- a/Events/messageUpdate.js +++ b/Events/messageUpdate.js @@ -4,8 +4,8 @@ const yaml = require("js-yaml"); const Event = require("../Structures/Event.js"); // Load the configuration files -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("../Structures/ConfigStore").supportbot; +const msgconfig = require("../Structures/ConfigStore").messages; module.exports = new Event("messageUpdate", async (client, oldMessage, newMessage) => { diff --git a/Events/ready.js b/Events/ready.js index ccbb15c..2db3a7f 100644 --- a/Events/ready.js +++ b/Events/ready.js @@ -1,126 +1,8 @@ -const fs = require("fs"); -const Discord = require("discord.js"); -const yaml = require("js-yaml"); const Event = require("../Structures/Event.js"); - -// Load configs -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); - -const ACTIVITY_TYPES = { - PLAYING: Discord.ActivityType.Playing, - WATCHING: Discord.ActivityType.Watching, - LISTENING: Discord.ActivityType.Listening, - COMPETING: Discord.ActivityType.Competing -}; - -const setActivity = (client, type, status) => { - const activityType = ACTIVITY_TYPES[type?.toUpperCase()]; - if (!activityType) return; - - client.user.setPresence({ - activities: [{ name: status || "Online", type: activityType }], - status: "online" - }); -}; - -// simple colors -const c = { - reset: "\x1b[0m", - gray: "\x1b[90m", - green: "\x1b[32m", - yellow: "\x1b[33m", - cyan: "\x1b[36m", - bold: "\x1b[1m" -}; - -function line() { - console.log(`${c.gray}────────────────────────────────────────────────────────${c.reset}`); -} - -function section(title) { - console.log(`\n${c.green}${title}${c.reset}`); - console.log(`${c.gray}${"-".repeat(title.length)}${c.reset}`); -} - -function info(label, value) { - console.log(`${c.gray}${label.padEnd(14)}${c.reset} ${value}`); -} - -function status(label, ok, details = "") { - const icon = ok ? `${c.green}✓${c.reset}` : `${c.yellow}!${c.reset}`; - console.log(` ${icon} ${label}${details ? ` (${details})` : ""}`); -} - -const logStartup = async (client) => { - console.clear(); - - const botName = supportbot?.General?.Name || "SupportBot"; - const botVersion = supportbot?.SupportBot_Version || "Unknown"; - - const inviteUrl = `https://discord.com/api/oauth2/authorize?client_id=${client.user.id}&permissions=8&scope=bot%20applications.commands`; - - line(); - console.log(`${c.bold}${c.green}SupportBot${c.reset}`); - console.log(`${c.gray}Emerald Services${c.reset}`); - line(); - - section("System"); - info("Bot Name", botName); - info("Version", botVersion); - info("Node.js", process.version); - info("Discord.js", `v${Discord.version}`); - info("Logged in as", client.user.tag); - info("Guilds", `${client.guilds.cache.size}`); - - section("Links"); - info("Docs", "https://emerald-services.gitbook.io/"); - info("Discord", "https://dsc.gg/emerald-dev"); - info("Invite", inviteUrl); - - console.log(""); -}; - -const checkConfigs = async (client) => { - const guild = client.guilds.cache.first(); - if (!guild) return; - - section("Config Check"); - - const roles = [ - supportbot?.Roles?.StaffMember?.Admin, - supportbot?.Roles?.StaffMember?.Staff, - supportbot?.Roles?.AutoRole?.Role - ].filter(Boolean); - - const channels = [ - supportbot?.Ticket?.TicketHome - ].filter(Boolean); - - const [roleResults, channelResults] = await Promise.all([ - Promise.all(roles.map((role) => client.getRole(role, guild))), - Promise.all(channels.map((channel) => client.getChannel(channel, guild))) - ]); - - const missingRoles = roles.filter((_, i) => !roleResults[i]); - const missingChannels = channels.filter((_, i) => !channelResults[i]); - - if (!missingRoles.length && !missingChannels.length) { - status("All configurations validated successfully", true); - } else { - if (missingRoles.length) { - status("Missing roles", false, missingRoles.join(", ")); - } - - if (missingChannels.length) { - status("Missing channels", false, missingChannels.join(", ")); - } - } - - console.log(""); -}; +const { runBotReady } = require("../Structures/BotStartup.js"); module.exports = new Event("clientReady", async (client) => { - setActivity(client, supportbot?.Activity?.Type, supportbot?.Activity?.Status); - await logStartup(client); - await checkConfigs(client); -}); \ No newline at end of file + // restartDiscordBot runs startup manually — clientReady often does not re-fire anyway + if (client.__explicitStartup) return; + await runBotReady(client, { clearConsole: true, logCommands: false }); +}); diff --git a/README.md b/README.md index e40b8c3..bcf741b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@


- +
SupportBot
@@ -28,18 +28,15 @@ | SupportBot | Node.JS | Discord.JS | Supported | |------------|---------|------------|------------| -| 8.x | 22.x | 14.x. | ✅ Yes -| 7.7.3 | 16.x | 13.x. | ❌ No -| 7.x | 16.x | 13.x. | ❌ No -| 6.x | 14.x | 13.x. | ❌ No -| 5.x | 12.x | 13.x. | ❌ No -| 4.x | 11.x | 13.x. | ❌ No -| Older | 10.x | 13.x. | ❌ No +| 26.x | 22.x | 14.x | ✅ Yes | +| 8.x | 22.x | 14.x | ❌ No | +| 7.x | 16.x | 13.x | ❌ No | +| Older | 10.x | 13.x | ❌ No |

- Website + Website • Wiki
@@ -60,7 +57,7 @@ SupportBot is an Open-Source discord bot built with [discord.js](https://github. ## Support - + Discord @@ -72,10 +69,9 @@ Released under the [MIT](https://opensource.org/licenses/MIT) license. ## Third-Party -- [Official addons repository](https://github.com/Emerald-Services/Addons/) -- [Syphers Addons](https://github.com/SypherRed/SB_Addons_Unofficial) -- [BuiltByBit]([https://builtbybit.com/supportbot](https://builtbybit.com/resources/supportbot-advanced-ticket-bot.8940/)) -- [Polymart](https://polymart.org/resource/supportbot-1-discord-ticket-bot.518) +- [Verified Addons](https://emeraldsrv.dev) +- [BuiltByBit](https://builtbybit.com/resources/supportbot-advanced-ticket-bot.8940/) +- [Voxel](https://polymart.org/resource/supportbot-1-discord-ticket-bot.518) ## Contributors @@ -86,11 +82,4 @@ Released under the [MIT](https://opensource.org/licenses/MIT) license. ## Hosting -You can use our [Pterodactyl egg](https://github.com/Emerald-Services/Pterodactyl-Egg) to host it on your own panel. We currently don't offer hosting for you, altough we might add this in the future. -If you don't know how to install pterodactyl, you could always host it on replit: - -[![Run on Repl.it](https://repl.it/badge/github/ItsNyoty/IKI-bot)]([https://repl.it/github/ItsNyoty/iki](https://repl.it/github/Emerald-Services/SupportBot -) - - - +You can use our [Pterodactyl egg](https://github.com/Emerald-Services/Pterodactyl-Egg) to host SupportBot on your own panel. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index c8dbd00..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,20 +0,0 @@ -# Security Policy - -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 7.7.2 | :white_check_mark: | -| 7.7.1 | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/Structures/AIDatabase.js b/Structures/AIDatabase.js index 0a672a1..fb9de50 100644 --- a/Structures/AIDatabase.js +++ b/Structures/AIDatabase.js @@ -1,438 +1,167 @@ -const Database = require("better-sqlite3"); -const path = require("path"); +const fs = require('fs'); +const path = require('path'); -const db = new Database(path.join(__dirname, "../Data/ai-memory.db")); +const DB_PATH = path.join(__dirname, '../Data/ai-memory.json'); -db.pragma("journal_mode = WAL"); +function load() { + try { + const raw = fs.readFileSync(DB_PATH, 'utf8'); + return JSON.parse(raw); + } catch (e) { + return { + ai_conversations: [], + ai_summaries: [], + ai_facts: [], + ai_knowledge: [], + ai_sessions: [], + }; + } +} -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ai_conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - channel_id TEXT, - user_id TEXT, - role TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL - ) -`, -).run(); +function save(state) { + try { + fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + fs.writeFileSync(DB_PATH, JSON.stringify(state, null, 2), 'utf8'); + } catch (e) { + console.error('Failed to persist ai-memory:', e); + } +} -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ai_summaries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - channel_id TEXT, - summary TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) -`, -).run(); +const state = load(); -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ai_facts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - channel_id TEXT, - user_id TEXT, - fact_type TEXT NOT NULL, - fact_key TEXT NOT NULL, - fact_value TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) -`, -).run(); - -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ai_knowledge ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_type TEXT NOT NULL, - source_name TEXT NOT NULL, - title TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) -`, -).run(); - -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ai_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - channel_id TEXT, - user_id TEXT, - model TEXT, - mode TEXT, - last_message_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) -`, -).run(); - -try { - db.prepare(`ALTER TABLE ai_facts ADD COLUMN user_id TEXT`).run(); -} catch (e) { - if (!String(e).includes("duplicate column name")) throw e; +function nextId(arr) { + return arr.length === 0 ? 1 : Math.max(...arr.map(a => a.id || 0)) + 1; } module.exports = { saveUserFact({ guildId, userId, factType, factKey, factValue }) { - const existing = db - .prepare( - ` - SELECT id FROM ai_facts - WHERE guild_id IS ? AND user_id IS ? AND fact_type = ? AND fact_key = ? - LIMIT 1 - `, - ) - .get(guildId || null, userId || null, factType, factKey); - + const existing = state.ai_facts.find(f => f.guild_id === (guildId || null) && f.user_id === (userId || null) && f.fact_type === factType && f.fact_key === factKey); if (existing) { - db.prepare( - ` - UPDATE ai_facts - SET fact_value = ?, updated_at = ? - WHERE id = ? - `, - ).run(factValue, Date.now(), existing.id); + existing.fact_value = factValue; + existing.updated_at = Date.now(); + save(state); return; } - db.prepare( - ` - INSERT INTO ai_facts ( - guild_id, channel_id, user_id, fact_type, fact_key, fact_value, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - guildId || null, - null, - userId || null, - factType, - factKey, - factValue, - Date.now(), - Date.now(), - ); + const id = nextId(state.ai_facts); + state.ai_facts.push({ id, guild_id: guildId || null, channel_id: null, user_id: userId || null, fact_type: factType, fact_key: factKey, fact_value: factValue, created_at: Date.now(), updated_at: Date.now() }); + save(state); }, getUserFacts(guildId, userId) { - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE guild_id IS ? AND user_id IS ? - ORDER BY updated_at DESC - `, - ) - .all(guildId || null, userId || null); + return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at); }, getUserFactsByScope({ guildId = null, userId = null, scope = "guild" }) { if (!userId) return []; - if (scope === "global") { - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE user_id IS ? - ORDER BY updated_at DESC - `, - ) - .all(userId || null); + return state.ai_facts.filter(f => f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at); } - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE guild_id IS ? AND user_id IS ? - ORDER BY updated_at DESC - `, - ) - .all(guildId || null, userId || null); + return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at); }, addMessage({ guildId, channelId, userId, role, content }) { - db.prepare( - ` - INSERT INTO ai_conversations ( - guild_id, channel_id, user_id, role, content, created_at - ) VALUES (?, ?, ?, ?, ?, ?) - `, - ).run( - guildId || null, - channelId || null, - userId || null, - role, - content, - Date.now(), - ); + const id = nextId(state.ai_conversations); + state.ai_conversations.push({ id, guild_id: guildId || null, channel_id: channelId || null, user_id: userId || null, role, content, created_at: Date.now() }); + save(state); }, getRecentMessages(channelId, limit = 15) { - return db - .prepare( - ` - SELECT * FROM ai_conversations - WHERE channel_id = ? - ORDER BY created_at DESC - LIMIT ? - `, - ) - .all(channelId, limit) - .reverse(); + const msgs = state.ai_conversations.filter(m => m.channel_id === channelId).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse(); + return msgs; }, getRecentMessagesByScope({ guildId = null, channelId = null, limit = 15, scope = "guild" }) { if (scope === "channel") { - return db - .prepare( - ` - SELECT * FROM ai_conversations - WHERE channel_id = ? - ORDER BY created_at DESC - LIMIT ? - `, - ) - .all(channelId || null, limit) - .reverse(); + return state.ai_conversations.filter(m => m.channel_id === (channelId || null)).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse(); } if (scope === "global") { - return db - .prepare( - ` - SELECT * FROM ai_conversations - ORDER BY created_at DESC - LIMIT ? - `, - ) - .all(limit) - .reverse(); + return state.ai_conversations.sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse(); } - return db - .prepare( - ` - SELECT * FROM ai_conversations - WHERE guild_id IS ? - ORDER BY created_at DESC - LIMIT ? - `, - ) - .all(guildId || null, limit) - .reverse(); + return state.ai_conversations.filter(m => m.guild_id === (guildId || null)).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse(); }, saveSummary({ guildId, channelId, summary }) { - const existing = db - .prepare( - ` - SELECT id FROM ai_summaries - WHERE guild_id IS ? AND channel_id IS ? - ORDER BY updated_at DESC - LIMIT 1 - `, - ) - .get(guildId || null, channelId || null); - + const existing = state.ai_summaries.find(s => s.guild_id === (guildId || null) && s.channel_id === (channelId || null)); if (existing) { - db.prepare( - ` - UPDATE ai_summaries - SET summary = ?, updated_at = ? - WHERE id = ? - `, - ).run(summary, Date.now(), existing.id); + existing.summary = summary; + existing.updated_at = Date.now(); + save(state); return; } - - db.prepare( - ` - INSERT INTO ai_summaries ( - guild_id, channel_id, summary, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?) - `, - ).run(guildId || null, channelId || null, summary, Date.now(), Date.now()); + const id = nextId(state.ai_summaries); + state.ai_summaries.push({ id, guild_id: guildId || null, channel_id: channelId || null, summary, created_at: Date.now(), updated_at: Date.now() }); + save(state); }, getLatestSummary(channelId) { - return db - .prepare( - ` - SELECT * FROM ai_summaries - WHERE channel_id = ? - ORDER BY updated_at DESC - LIMIT 1 - `, - ) - .get(channelId); + const arr = state.ai_summaries.filter(s => s.channel_id === channelId).sort((a,b) => b.updated_at - a.updated_at); + return arr.length ? arr[0] : null; }, getLatestSummaryByScope({ guildId = null, channelId = null, scope = "guild" }) { if (scope === "channel") { - return db - .prepare( - ` - SELECT * FROM ai_summaries - WHERE channel_id = ? - ORDER BY updated_at DESC - LIMIT 1 - `, - ) - .get(channelId || null); + const arr = state.ai_summaries.filter(s => s.channel_id === (channelId || null)).sort((a,b) => b.updated_at - a.updated_at); + return arr.length ? arr[0] : null; } - if (scope === "global") { - return db - .prepare( - ` - SELECT * FROM ai_summaries - ORDER BY updated_at DESC - LIMIT 1 - `, - ) - .get(); + const arr = state.ai_summaries.sort((a,b) => b.updated_at - a.updated_at); + return arr.length ? arr[0] : null; } - return db - .prepare( - ` - SELECT * FROM ai_summaries - WHERE guild_id IS ? - ORDER BY updated_at DESC - LIMIT 1 - `, - ) - .get(guildId || null); + const arr = state.ai_summaries.filter(s => s.guild_id === (guildId || null)).sort((a,b) => b.updated_at - a.updated_at); + return arr.length ? arr[0] : null; }, saveFact({ guildId, channelId, factType, factKey, factValue }) { - const existing = db - .prepare( - ` - SELECT id FROM ai_facts - WHERE guild_id IS ? AND channel_id IS ? AND fact_type = ? AND fact_key = ? - LIMIT 1 - `, - ) - .get(guildId || null, channelId || null, factType, factKey); - + const existing = state.ai_facts.find(f => f.guild_id === (guildId || null) && f.channel_id === (channelId || null) && f.fact_type === factType && f.fact_key === factKey); if (existing) { - db.prepare( - ` - UPDATE ai_facts - SET fact_value = ?, updated_at = ? - WHERE id = ? - `, - ).run(factValue, Date.now(), existing.id); + existing.fact_value = factValue; + existing.updated_at = Date.now(); + save(state); return; } - - db.prepare( - ` - INSERT INTO ai_facts ( - guild_id, channel_id, fact_type, fact_key, fact_value, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ).run( - guildId || null, - channelId || null, - factType, - factKey, - factValue, - Date.now(), - Date.now(), - ); + const id = nextId(state.ai_facts); + state.ai_facts.push({ id, guild_id: guildId || null, channel_id: channelId || null, user_id: null, fact_type: factType, fact_key: factKey, fact_value: factValue, created_at: Date.now(), updated_at: Date.now() }); + save(state); }, getFacts(channelId) { - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE channel_id = ? - ORDER BY updated_at DESC - `, - ) - .all(channelId); + return state.ai_facts.filter(f => f.channel_id === channelId).sort((a,b) => b.updated_at - a.updated_at); }, getFactsByScope({ guildId = null, channelId = null, scope = "guild" }) { if (scope === "channel") { - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE channel_id = ? AND user_id IS NULL - ORDER BY updated_at DESC - `, - ) - .all(channelId || null); + return state.ai_facts.filter(f => f.channel_id === (channelId || null) && f.user_id == null).sort((a,b) => b.updated_at - a.updated_at); } - if (scope === "global") { - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE user_id IS NULL - ORDER BY updated_at DESC - `, - ) - .all(); + return state.ai_facts.filter(f => f.user_id == null).sort((a,b) => b.updated_at - a.updated_at); } - return db - .prepare( - ` - SELECT * FROM ai_facts - WHERE guild_id IS ? AND user_id IS NULL - ORDER BY updated_at DESC - `, - ) - .all(guildId || null); + return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id == null).sort((a,b) => b.updated_at - a.updated_at); }, addKnowledge({ sourceType, sourceName, title, content }) { - db.prepare( - ` - INSERT INTO ai_knowledge ( - source_type, source_name, title, content, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?) - `, - ).run(sourceType, sourceName, title, content, Date.now(), Date.now()); + const id = nextId(state.ai_knowledge); + state.ai_knowledge.push({ id, source_type: sourceType, source_name: sourceName, title, content, created_at: Date.now(), updated_at: Date.now() }); + save(state); }, getKnowledge(limit = 25) { - return db - .prepare( - ` - SELECT * FROM ai_knowledge - ORDER BY updated_at DESC - LIMIT ? - `, - ) - .all(limit); + return state.ai_knowledge.sort((a,b) => b.updated_at - a.updated_at).slice(0, limit); }, clearKnowledge() { - db.prepare(`DELETE FROM ai_knowledge`).run(); + state.ai_knowledge = []; + save(state); }, clearConversations(channelId) { if (!channelId) return; - db.prepare(`DELETE FROM ai_conversations WHERE channel_id = ?`).run(channelId); + state.ai_conversations = state.ai_conversations.filter(c => c.channel_id !== channelId); + save(state); }, }; \ No newline at end of file diff --git a/Structures/AddonCatalog.js b/Structures/AddonCatalog.js new file mode 100644 index 0000000..8f04ade --- /dev/null +++ b/Structures/AddonCatalog.js @@ -0,0 +1,185 @@ +const fs = require("fs"); +const path = require("path"); +const axios = require("axios"); +const { withTimeout } = require("./asyncTimeout.js"); +const AdmZip = require("adm-zip"); +const yaml = require("js-yaml"); + +const EMERALD_API = "https://emeraldsrv.dev/api/v1"; + +function getApiKey() { + try { + if (fs.existsSync("./Configs/api.yml")) { + const config = yaml.load(fs.readFileSync("./Configs/api.yml", "utf8")); + return config?.API?.EmeraldAPIKey || ""; + } + } catch (e) { + console.error("Failed to read api.yml:", e); + } + return ""; +} + +function ensureAddonDirs() { + if (!fs.existsSync("./Addons")) { + fs.mkdirSync("./Addons", { recursive: true }); + } + if (!fs.existsSync("./Addons/Configs")) { + fs.mkdirSync("./Addons/Configs", { recursive: true }); + } +} + +async function listCatalogAddons() { + const key = getApiKey(); + if (!key) throw new Error("EmeraldAPIKey not configured in api.yml"); + + const res = await withTimeout( + axios.get(`${EMERALD_API}/resources`, { + headers: { Authorization: `Bearer ${key}` }, + timeout: 15_000, + }), + 18_000, + "Emerald API request timed out", + ); + + if (res.data.status !== "success") { + throw new Error(res.data.message || "Failed to fetch catalog"); + } + + return res.data.data.map(item => ({ + id: item.id.toString(), + name: item.title, + slug: item.slug, + description: `Emerald Services Resource - ${item.slug}`, + price: item.price, + is_external: item.is_external, + external_store: item.external_store, + repositoryUrl: item.external_url || `https://emeraldsrv.dev`, + jsFiles: [], + configFiles: [] + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +async function downloadFile(id) { + const key = getApiKey(); + const res = await withTimeout( + axios.get(`${EMERALD_API}/download?id=${id}`, { + headers: { Authorization: `Bearer ${key}` }, + responseType: "arraybuffer", + timeout: 30_000 + }), + 35_000, + "Addon file download timed out", + ); + return Buffer.from(res.data); +} + +function listLocalAddonJs() { + ensureAddonDirs(); + return fs + .readdirSync("./Addons") + .filter((f) => f.endsWith(".js") && f !== "addon.js"); +} + +function listLocalAddonConfigs() { + ensureAddonDirs(); + const dir = "./Addons/Configs"; + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml") || f.endsWith(".json")); +} + +function isAddonInstalled(catalogEntry) { + const localJs = listLocalAddonJs(); + return localJs.some(f => f.includes(catalogEntry.slug) || f.includes(catalogEntry.id.toString()) || f.includes(catalogEntry.name.replace(/ /g, ''))); +} + +async function listInstalledAddons(catalog) { + const localJs = listLocalAddonJs(); + const installed = []; + + for (const file of localJs) { + const name = file.replace(/\.js$/, ""); + const match = catalog.find(c => c.slug === name || c.id.toString() === name || c.name.replace(/ /g, '') === name); + + installed.push({ + id: match ? match.id : name, + name: match ? match.name : name, + jsFiles: [file], + configFiles: listLocalAddonConfigs().filter(f => f.startsWith(name)), + custom: !match + }); + } + return installed; +} + +async function installAddonFromCatalog(addonId) { + const catalog = await listCatalogAddons(); + const addon = catalog.find(c => c.id === addonId.toString()); + + if (!addon) { + throw new Error(`Addon not found in catalog`); + } + + ensureAddonDirs(); + const content = await downloadFile(addon.id); + + const zip = new AdmZip(content); + zip.extractAllTo("./Addons", true); + + return { + addon: addon, + filesWritten: [], + }; +} + +function readAddonConfig(filename) { + const safe = path.basename(filename); + if (!/^[a-zA-Z0-9_.-]+\.(yml|yaml|json)$/.test(safe)) { + throw new Error("Invalid config filename"); + } + + const filePath = path.join("./Addons/Configs", safe); + if (!fs.existsSync(filePath)) { + throw new Error("Addon config not found"); + } + + const raw = fs.readFileSync(filePath, "utf8"); + if (safe.endsWith(".json")) { + return { filename: safe, format: "json", data: JSON.parse(raw), raw }; + } + + const yaml = require("js-yaml"); + return { filename: safe, format: "yaml", data: yaml.load(raw) || {}, raw }; +} + +function writeAddonConfig(filename, data) { + const safe = path.basename(filename); + if (!/^[a-zA-Z0-9_.-]+\.(yml|yaml|json)$/.test(safe)) { + throw new Error("Invalid config filename"); + } + + ensureAddonDirs(); + const filePath = path.join("./Addons/Configs", safe); + let content; + + if (safe.endsWith(".json")) { + content = `${JSON.stringify(data, null, 2)}\n`; + } else { + const yaml = require("js-yaml"); + content = yaml.dump(data, { lineWidth: 120, noRefs: true }); + } + + fs.writeFileSync(filePath, content); + return { filename: safe, path: filePath.replace(/\\/g, "/") }; +} + +module.exports = { + listCatalogAddons, + listInstalledAddons, + isAddonInstalled, + installAddonFromCatalog, + listLocalAddonConfigs, + readAddonConfig, + writeAddonConfig, +}; diff --git a/Structures/BotInvite.js b/Structures/BotInvite.js new file mode 100644 index 0000000..95b47a1 --- /dev/null +++ b/Structures/BotInvite.js @@ -0,0 +1,6 @@ +function getBotInviteUrl(clientId) { + if (!clientId) return null; + return `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=8&scope=bot%20applications.commands`; +} + +module.exports = { getBotInviteUrl }; diff --git a/Structures/BotReload.js b/Structures/BotReload.js new file mode 100644 index 0000000..475de29 --- /dev/null +++ b/Structures/BotReload.js @@ -0,0 +1,150 @@ +const fs = require("fs"); +const path = require("path"); +const configStore = require("./ConfigStore.js"); +const { getPrimaryGuild } = require("./GuildManager.js"); +const { + applyPresence, + runBotReady, + logSlashCommandsRegistered, +} = require("./BotStartup.js"); + +function walk(dir, ext = ".js", fileList = []) { + if (!fs.existsSync(dir)) return fileList; + for (const file of fs.readdirSync(dir)) { + const full = path.join(dir, file); + if (fs.statSync(full).isDirectory()) { + walk(full, ext, fileList); + } else if (file.endsWith(ext)) { + fileList.push(full.replace(/\\/g, "/")); + } + } + return fileList; +} + +async function refreshSlashCommands(client) { + if (!client?.application) return { count: 0 }; + + const cmdconfig = configStore.commands; + const commandFiles = walk("./Commands"); + client.commands.clear(); + + for (const file of commandFiles) { + const resolved = path.resolve(file); + if (require.cache[resolved]) { + delete require.cache[resolved]; + } + } + + let count = 0; + for (const file of commandFiles) { + const command = require(path.resolve(file)); + const matchingConfig = Object.values(cmdconfig).find( + (entry) => + entry && + typeof entry === "object" && + entry.Command && + String(entry.Command).toLowerCase() === + String(command.name).toLowerCase(), + ); + + if (matchingConfig && matchingConfig.Enabled === false) { + continue; + } + + client.commands.set(command.name, command); + count++; + } + + const payload = client.commands.map((cmd) => cmd.data ?? cmd); + for (const guild of client.guilds.cache.values()) { + await guild.commands.set(payload); + } + + return { count }; +} + +async function reloadBot(client, options = {}) { + const { fileKey, refreshCommands = true } = options; + + let reloaded; + if (fileKey) { + configStore.reloadFile(fileKey); + reloaded = [fileKey]; + } else { + reloaded = configStore.reloadBotConfigs(); + } + + applyPresence(client); + + let commands = null; + if (refreshCommands && (!fileKey || fileKey === "commands")) { + commands = await refreshSlashCommands(client); + } + + return { + reloaded, + commands, + }; +} + +/** + * Reload configs from disk, reconnect the Discord session, and refresh presence/commands. + * API and dashboard keep running on the same process. + */ +async function restartDiscordBot(client) { + if (!client) { + const err = new Error("Bot client not available"); + err.code = "NO_CLIENT"; + throw err; + } + + const reloaded = configStore.reloadBotConfigs(); + const token = configStore.supportbot?.General?.Token; + + if (!token || String(token).trim() === "" || token === "BOT_TOKEN") { + const err = new Error("Invalid or missing bot token in supportbot.yml"); + err.code = "INVALID_TOKEN"; + throw err; + } + + console.log("[BotReload] Restarting Discord bot after config change…"); + client.__restarting = true; + + if (client.user) { + try { + await client.destroy(); + } catch (destroyErr) { + console.warn("[BotReload] destroy:", destroyErr.message); + } + } + + client.__explicitStartup = true; + try { + try { + await client.login(token); + } catch (loginErr) { + console.error("[BotReload] login failed:", loginErr.message); + const err = new Error(`Could not start bot: ${loginErr.message}`); + err.code = "LOGIN_FAILED"; + throw err; + } + + // clientReady does not fire again after destroy()+login() — run startup explicitly + await runBotReady(client, { clearConsole: true, logCommands: false }); + const commands = await refreshSlashCommands(client); + logSlashCommandsRegistered(client); + + console.log("[BotReload] Bot restarted successfully"); + return { restarted: true, reloaded, commands }; + } finally { + client.__explicitStartup = false; + client.__restarting = false; + } +} + +module.exports = { + applyPresence, + refreshSlashCommands, + reloadBot, + restartDiscordBot, +}; diff --git a/Structures/BotStartup.js b/Structures/BotStartup.js new file mode 100644 index 0000000..7e67b72 --- /dev/null +++ b/Structures/BotStartup.js @@ -0,0 +1,200 @@ +const Discord = require("discord.js"); +const configStore = require("./ConfigStore.js"); +const { + getPrimaryGuild, + syncGuildHealth, +} = require("./GuildManager.js"); + +const ACTIVITY_TYPES = { + PLAYING: Discord.ActivityType.Playing, + WATCHING: Discord.ActivityType.Watching, + LISTENING: Discord.ActivityType.Listening, + COMPETING: Discord.ActivityType.Competing, + STREAMING: Discord.ActivityType.Streaming, +}; + +function applyPresence(client) { + if (!client?.user) return; + + const activity = configStore.supportbot?.Activity; + const typeKey = activity?.Type?.toUpperCase(); + const type = ACTIVITY_TYPES[typeKey] ?? Discord.ActivityType.Playing; + const name = activity?.Status || "Online"; + + const activityPayload = { name, type }; + + if (typeKey === "STREAMING" && activity?.StreamingURL) { + activityPayload.url = activity.StreamingURL; + } + + client.user.setPresence({ + activities: [activityPayload], + status: "online", + }); +} + +const c = { + reset: "\x1b[0m", + gray: "\x1b[90m", + green: "\x1b[32m", + yellow: "\x1b[33m", + bold: "\x1b[1m", + white: "\x1b[37m", +}; + +function line() { + console.log( + `${c.gray}────────────────────────────────────────────────────────${c.reset}`, + ); +} + +function section(title) { + console.log(`\n${c.green}${title}${c.reset}`); + console.log(`${c.gray}${"-".repeat(title.length)}${c.reset}`); +} + +function info(label, value) { + console.log(`${c.gray}${label.padEnd(14)}${c.reset} ${value}`); +} + +function status(label, ok, details = "") { + const icon = ok ? `${c.green}✓${c.reset}` : `${c.yellow}!${c.reset}`; + console.log(` ${icon} ${label}${details ? ` (${details})` : ""}`); +} + +async function logStartup(client, options = {}) { + const { clearConsole = true } = options; + const supportbot = configStore.supportbot; + + if (clearConsole) { + console.clear(); + } + + const botName = supportbot?.General?.Name || "SupportBot"; + const botVersion = supportbot?.SupportBot_Version || "Unknown"; + const { getBotInviteUrl } = require("./BotInvite.js"); + const inviteUrl = getBotInviteUrl(client.user.id); + + line(); + console.log(`${c.bold}${c.green}SupportBot${c.reset}`); + console.log(`${c.gray}Emerald Services${c.reset}`); + line(); + + section("System"); + info("Bot Name", botName); + info("Version", botVersion); + info("Node.js", process.version); + info("Discord.js", `v${Discord.version}`); + const db = require("./Database.js"); + const dbInfo = db.getDatabaseInfo ? db.getDatabaseInfo() : null; + if (dbInfo) { + const dbDisplay = dbInfo.type === "mysql" + ? `${dbInfo.driver} (${dbInfo.host}:${dbInfo.port}/${dbInfo.database})` + : `${dbInfo.driver} (${dbInfo.file})`; + info("Database", dbDisplay); + } + info("Logged in as", client.user.tag); + info("Guilds", `${client.guilds.cache.size}`); + + section("Links"); + info("Docs", "https://emerald-services.gitbook.io/"); + info("Discord", "https://dsc.gg/emerald-dev"); + + if (configStore.api?.API?.Enabled) { + const port = configStore.api?.API?.Port || 3000; + info("Dashboard", `http://localhost:${port}`); + } + + info("Invite", inviteUrl); + + console.log(""); +} + +function checkSetup() { + const { isSetupComplete } = require("./DashboardSetup.js"); + if (!isSetupComplete()) { + const port = configStore.api?.API?.Port || 25575; + section("Setup Required"); + status("Onboarding setup incomplete", false, `http://localhost:${port}/setup`); + console.log(""); + } +} + +async function checkConfigs(client) { + const supportbot = configStore.supportbot; + const health = syncGuildHealth(client); + + if (health.status !== "ok") { + section("Guild"); + status("Guild setup", false, health.message || health.status); + console.log(""); + } + + const guild = getPrimaryGuild(client); + if (!guild) return; + + section("Config Check"); + + const roles = [ + supportbot?.Roles?.StaffMember?.Admin, + supportbot?.Roles?.StaffMember?.Staff, + supportbot?.Roles?.AutoRole?.Role, + ].filter(Boolean); + + const channels = [supportbot?.Ticket?.TicketHome].filter(Boolean); + + const [roleResults, channelResults] = await Promise.all([ + Promise.all(roles.map((role) => client.getRole(role, guild))), + Promise.all(channels.map((channel) => client.getChannel(channel, guild))), + ]); + + const missingRoles = roles.filter((_, i) => !roleResults[i]); + const missingChannels = channels.filter((_, i) => !channelResults[i]); + + if (!missingRoles.length && !missingChannels.length) { + status("All configurations validated successfully", true); + } else { + if (missingRoles.length) { + status("Missing roles", false, missingRoles.join(", ")); + } + if (missingChannels.length) { + status("Missing channels", false, missingChannels.join(", ")); + } + } + + console.log(""); +} + +function logSlashCommandsRegistered(client) { + const guild = getPrimaryGuild(client); + if (!guild) return; + console.log( + `\n${c.green}✓${c.reset} ${c.white}Slash Commands Registered${c.reset} ${c.gray}for ${guild.name}${c.reset}`, + ); +} + +/** + * Same work as Events/ready.js — safe to call after config reload reconnects. + */ +async function runBotReady(client, options = {}) { + const { clearConsole = true, logCommands = false } = options; + + syncGuildHealth(client); + applyPresence(client); + await logStartup(client, { clearConsole }); + await checkConfigs(client); + + if (logCommands) { + logSlashCommandsRegistered(client); + } + + checkSetup(); +} + +module.exports = { + applyPresence, + logStartup, + checkConfigs, + logSlashCommandsRegistered, + runBotReady, +}; diff --git a/Structures/Client.js b/Structures/Client.js index 97392d6..9db0cbe 100644 --- a/Structures/Client.js +++ b/Structures/Client.js @@ -2,11 +2,12 @@ const fs = require("fs"); const path = require("path"); const Discord = require("discord.js"); const { GatewayIntentBits, Partials } = require("discord.js"); -const yaml = require("js-yaml"); const { Command, Event } = require("./Addon.js"); +const configStore = require("./ConfigStore.js"); +const { getPrimaryGuild } = require("./GuildManager.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); +const supportbot = configStore.supportbot; +const cmdconfig = configStore.commands; const c = { reset: "\x1b[0m", @@ -50,6 +51,7 @@ class Client extends Discord.Client { GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildEmojisAndStickers, GatewayIntentBits.MessageContent, ], partials: [Partials.Message, Partials.Channel, Partials.Reaction], @@ -158,12 +160,12 @@ class Client extends Discord.Client { }); } - this.once("clientReady", async () => { - await this.guilds.cache.first()?.commands.set(this.commands); - - console.log( - `\n${c.green}✓${c.reset} ${c.white}Slash Commands Registered${c.reset} ${c.gray}for ${this.guilds.cache.first().name}${c.reset}`, - ); + this.on("clientReady", async () => { + const guild = getPrimaryGuild(this); + if (!guild) return; + await guild.commands.set(this.commands); + const { logSlashCommandsRegistered } = require("./BotStartup.js"); + logSlashCommandsRegistered(this); }); section("Events"); diff --git a/Structures/ConfigStore.js b/Structures/ConfigStore.js new file mode 100644 index 0000000..40a0416 --- /dev/null +++ b/Structures/ConfigStore.js @@ -0,0 +1,89 @@ +// Live config objects — reload mutates in place so existing references stay valid. +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +const CONFIG_DIR = path.join(__dirname, "../Configs"); + +const FILE_KEYS = [ + "supportbot", + "commands", + "messages", + "ticket-panel", + "supportbot-ai", + "api", +]; + +function readConfig(name) { + return yaml.load(fs.readFileSync(path.join(CONFIG_DIR, `${name}.yml`), "utf8")); +} + +function deepAssign(target, source) { + if (!target || typeof target !== "object" || Array.isArray(target)) { + return source; + } + if (!source || typeof source !== "object" || Array.isArray(source)) { + return source; + } + + for (const key of Object.keys(source)) { + const next = source[key]; + if ( + next && + typeof next === "object" && + !Array.isArray(next) && + target[key] && + typeof target[key] === "object" && + !Array.isArray(target[key]) + ) { + deepAssign(target[key], next); + } else { + target[key] = next; + } + } + + for (const key of Object.keys(target)) { + if (!(key in source)) { + delete target[key]; + } + } + + return target; +} + +const store = {}; +for (const name of FILE_KEYS) { + store[name] = readConfig(name); +} + +function reloadFile(fileKey) { + if (!FILE_KEYS.includes(fileKey)) { + throw new Error(`Unknown config: ${fileKey}`); + } + const fresh = readConfig(fileKey); + deepAssign(store[fileKey], fresh); + return store[fileKey]; +} + +function reloadBotConfigs() { + const reloaded = []; + for (const name of FILE_KEYS) { + if (name === "api") continue; + reloadFile(name); + reloaded.push(name); + } + return reloaded; +} + +module.exports = { + supportbot: store.supportbot, + commands: store.commands, + messages: store.messages, + ticketPanel: store["ticket-panel"], + supportbotAi: store["supportbot-ai"], + api: store.api, + store, + reloadFile, + reloadBotConfigs, + readConfig, +}; diff --git a/Structures/DashboardAlerts.js b/Structures/DashboardAlerts.js new file mode 100644 index 0000000..c37dc42 --- /dev/null +++ b/Structures/DashboardAlerts.js @@ -0,0 +1,229 @@ +/** + * Dashboard alert registry — server-side providers for actionable banners. + * + * Register a provider to surface new alerts anywhere in the dashboard: + * + * const { dashboardAlerts } = require("./DashboardAlerts.js"); + * dashboardAlerts.register("my-addon", (ctx) => { + * if (!ctx.configStore.myAddon?.Configured) { + * return [{ + * id: "my-addon:setup", + * severity: "warning", + * category: "my-addon", + * title: "My addon needs setup", + * message: "Finish configuration to enable this feature.", + * action: { label: "Configure", href: "/configs?file=my-addon" }, + * }]; + * } + * return []; + * }); + */ +const { evaluateGuildHealth } = require("./GuildManager.js"); +const { evaluateAllModules } = require("./ModuleStatus.js"); + +const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 }; + +const MODULE_LINKS = { + tickets: { + title: "Ticket system", + href: "/configs?file=ticket-panel", + }, + supportbot: { + title: "Core bot settings", + href: "/configs?file=supportbot", + }, + transcripts: { + title: "Ticket transcripts", + href: "/transcripts", + }, + suggestions: { + title: "Suggestions", + href: "/configs?file=messages", + }, + ai: { + title: "AI assistant", + href: "/configs?file=supportbot-ai", + }, + commands: { + title: "Commands", + href: "/configs?file=commands", + }, +}; + +class DashboardAlertRegistry { + constructor() { + /** @type {Map DashboardAlert[] | Promise>} */ + this.providers = new Map(); + } + + /** + * @param {string} id Unique provider id (used for debugging / overrides) + * @param {(ctx: DashboardAlertContext) => DashboardAlert[] | Promise} fn + */ + register(id, fn) { + this.providers.set(id, fn); + return this; + } + + unregister(id) { + this.providers.delete(id); + } + + /** @param {DashboardAlertContext} ctx */ + async evaluate(ctx) { + const alerts = []; + for (const fn of this.providers.values()) { + const result = await fn(ctx); + if (Array.isArray(result) && result.length) { + alerts.push(...result); + } + } + return sortAlerts(alerts); + } +} + +function sortAlerts(alerts) { + return [...alerts].sort((a, b) => { + const sa = SEVERITY_ORDER[a.severity] ?? 9; + const sb = SEVERITY_ORDER[b.severity] ?? 9; + if (sa !== sb) return sa - sb; + return a.title.localeCompare(b.title); + }); +} + +function guildSeverity(status) { + if (status === "offline") return "error"; + return "warning"; +} + +function shouldSkipModuleAlert(moduleId, info) { + if (info.status === "done") return true; + if (moduleId === "ai" && info.subtitle === "AI is disabled in config") return true; + // Transcripts are automatic — empty archive is normal, not a setup step. + if (moduleId === "transcripts") return true; + // Partially configured modules are tracked on Overview — only banner when nothing is done yet. + if (info.status === "progress" && (info.passed ?? 0) > 0) return true; + return false; +} + +function moduleSeverity(moduleId, info) { + if (info.status === "todo") { + return moduleId === "tickets" || moduleId === "supportbot" ? "warning" : "info"; + } + return "info"; +} + +function evaluateGuildAlerts(ctx) { + const { client } = ctx; + if (!client?.user) { + return [ + { + id: "guild:offline", + severity: "error", + category: "guild", + title: "Bot offline", + message: "The bot is not connected to Discord. Check your token and restart the server.", + action: { label: "Bot config", href: "/configs?file=supportbot" }, + meta: { status: "offline" }, + }, + ]; + } + + const health = evaluateGuildHealth(client); + if (health.status === "ok") return []; + + return [ + { + id: "guild:health", + severity: guildSeverity(health.status), + category: "guild", + title: "Server setup required", + message: health.message, + action: { label: "Bot config", href: "/configs?file=supportbot" }, + meta: { + status: health.status, + configuredGuildId: health.configuredGuildId, + configuredGuild: health.configuredGuild, + extraGuilds: health.extraGuilds, + }, + }, + ]; +} + +function evaluateModuleAlerts(ctx) { + const { configStore, transcriptCount = 0 } = ctx; + const modules = evaluateAllModules(configStore, { transcriptCount }); + const alerts = []; + + for (const [moduleId, info] of Object.entries(modules)) { + if (shouldSkipModuleAlert(moduleId, info)) continue; + + const link = MODULE_LINKS[moduleId] || { + title: moduleId, + href: "/configs", + }; + + const progress = + info.total > 0 ? `${info.passed ?? 0} of ${info.total} checks complete` : null; + + alerts.push({ + id: `module:${moduleId}`, + severity: moduleSeverity(moduleId, info), + category: "modules", + title: + info.status === "todo" + ? `${link.title} — setup required` + : `${link.title} — in progress`, + message: info.subtitle, + action: { label: "Open module", href: link.href }, + meta: { + moduleId, + status: info.status, + passed: info.passed, + total: info.total, + progress, + }, + }); + } + + return alerts; +} + +const dashboardAlerts = new DashboardAlertRegistry(); +dashboardAlerts.register("guild", evaluateGuildAlerts); +dashboardAlerts.register("modules", evaluateModuleAlerts); + +/** + * @typedef {Object} DashboardAlertAction + * @property {string} label + * @property {string} href + */ + +/** + * @typedef {Object} DashboardAlert + * @property {string} id + * @property {"error"|"warning"|"info"} severity + * @property {string} category + * @property {string} title + * @property {string} message + * @property {DashboardAlertAction} [action] + * @property {Record} [meta] + */ + +/** + * @typedef {Object} DashboardAlertContext + * @property {import("discord.js").Client} [client] + * @property {import("./ConfigStore.js")} configStore + * @property {number} [transcriptCount] + */ + +async function evaluateDashboardAlerts(ctx) { + return dashboardAlerts.evaluate(ctx); +} + +module.exports = { + dashboardAlerts, + evaluateDashboardAlerts, + sortAlerts, + MODULE_LINKS, +}; diff --git a/Structures/DashboardAuth.js b/Structures/DashboardAuth.js new file mode 100644 index 0000000..089be04 --- /dev/null +++ b/Structures/DashboardAuth.js @@ -0,0 +1,57 @@ +const { fullPermissions, hasPermission, canAccessConfig } = require("./DashboardPermissions.js"); + +function grantServiceAccess(req) { + req.dashboardService = true; + req.dashboardPermissions = fullPermissions(); + req.dashboardRole = "service"; + req.dashboardIsOwner = true; +} + +function forbid(res, message = "Forbidden") { + return res.status(403).json({ success: false, error: message }); +} + +function createPermissionMiddleware(getPermissions) { + return function requirePermission(permission) { + return (req, res, next) => { + if (req.dashboardService) return next(); + const perms = getPermissions(req) || req.dashboardPermissions; + if (hasPermission(perms, permission)) return next(); + return forbid(res); + }; + }; +} + +function createConfigMiddleware(getPermissions) { + return function requireConfigAccess(mode = "view") { + return (req, res, next) => { + if (req.dashboardService) return next(); + const perms = getPermissions(req) || req.dashboardPermissions; + const file = req.params?.file || req.body?.filename; + if (!file) return next(); + if (!canAccessConfig(perms, file, mode)) return forbid(res); + return next(); + }; + }; +} + +function assertConfigFilesEditable(permissions, files, res) { + if (!permissions) return forbid(res); + for (const file of files) { + if (!canAccessConfig(permissions, file, "edit")) { + return forbid(res, `No permission to edit ${file}.yml`); + } + } + return null; +} + +module.exports = { + grantServiceAccess, + forbid, + createPermissionMiddleware, + createConfigMiddleware, + assertConfigFilesEditable, + fullPermissions, + hasPermission, + canAccessConfig, +}; diff --git a/Structures/DashboardBranding.js b/Structures/DashboardBranding.js new file mode 100644 index 0000000..5db07e7 --- /dev/null +++ b/Structures/DashboardBranding.js @@ -0,0 +1,168 @@ +const fs = require('fs'); +const path = require('path'); + +const BRANDING_DIR = path.join(__dirname, '../Data/branding'); +const BRANDING_FILE = path.join(BRANDING_DIR, 'branding.json'); +const FAVICON_BASENAME = 'favicon'; + +const DEFAULTS = { + title: 'SupportBot', + pageTitle: 'SupportBot Dashboard', + faviconFile: null, + faviconMime: null, + updatedAt: null, +}; + +const ALLOWED_MIME = new Set([ + 'image/png', + 'image/jpeg', + 'image/x-icon', + 'image/vnd.microsoft.icon', + 'image/svg+xml', + 'image/webp', +]); + +const MIME_EXT = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/x-icon': '.ico', + 'image/vnd.microsoft.icon': '.ico', + 'image/svg+xml': '.svg', + 'image/webp': '.webp', +}; + +function ensureDir() { + if (!fs.existsSync(BRANDING_DIR)) { + fs.mkdirSync(BRANDING_DIR, { recursive: true }); + } +} + +function readBranding() { + ensureDir(); + if (!fs.existsSync(BRANDING_FILE)) { + return { ...DEFAULTS }; + } + try { + const raw = JSON.parse(fs.readFileSync(BRANDING_FILE, 'utf8')); + return { + title: String(raw.title || DEFAULTS.title).slice(0, 64), + pageTitle: String(raw.pageTitle || DEFAULTS.pageTitle).slice(0, 80), + faviconFile: raw.faviconFile || null, + faviconMime: raw.faviconMime || null, + updatedAt: raw.updatedAt || null, + }; + } catch { + return { ...DEFAULTS }; + } +} + +function writeBranding(data) { + ensureDir(); + const next = { + title: String(data.title || DEFAULTS.title).slice(0, 64), + pageTitle: String(data.pageTitle || DEFAULTS.pageTitle).slice(0, 80), + faviconFile: data.faviconFile ?? null, + faviconMime: data.faviconMime ?? null, + updatedAt: Date.now(), + }; + fs.writeFileSync(BRANDING_FILE, JSON.stringify(next, null, 2), 'utf8'); + return next; +} + +function publicPayload(branding) { + const hasFavicon = Boolean(branding.faviconFile); + return { + title: branding.title, + pageTitle: branding.pageTitle, + hasFavicon, + faviconUrl: hasFavicon ? '/api/branding/favicon' : null, + updatedAt: branding.updatedAt, + }; +} + +function updateBranding({ title, pageTitle }) { + const current = readBranding(); + return writeBranding({ + ...current, + title: title !== undefined ? title : current.title, + pageTitle: pageTitle !== undefined ? pageTitle : current.pageTitle, + }); +} + +function removeExistingFavicons() { + if (!fs.existsSync(BRANDING_DIR)) return; + for (const file of fs.readdirSync(BRANDING_DIR)) { + if (file.startsWith(`${FAVICON_BASENAME}.`)) { + fs.unlinkSync(path.join(BRANDING_DIR, file)); + } + } +} + +function saveFaviconFromBase64(dataUrl) { + const match = String(dataUrl || '').match( + /^data:([\w/+.-]+);base64,([A-Za-z0-9+/=]+)$/, + ); + if (!match) { + throw new Error('Invalid image data. Upload a PNG, ICO, SVG, or WebP file.'); + } + + const mime = match[1]; + if (!ALLOWED_MIME.has(mime)) { + throw new Error('Unsupported image type. Use PNG, ICO, SVG, JPEG, or WebP.'); + } + + const buffer = Buffer.from(match[2], 'base64'); + if (buffer.length > 512 * 1024) { + throw new Error('Favicon must be 512 KB or smaller.'); + } + + ensureDir(); + removeExistingFavicons(); + + const ext = MIME_EXT[mime] || '.png'; + const filename = `${FAVICON_BASENAME}${ext}`; + const filePath = path.join(BRANDING_DIR, filename); + fs.writeFileSync(filePath, buffer); + + const current = readBranding(); + return writeBranding({ + ...current, + faviconFile: filename, + faviconMime: mime, + }); +} + +function clearFavicon() { + removeExistingFavicons(); + const current = readBranding(); + return writeBranding({ + ...current, + faviconFile: null, + faviconMime: null, + }); +} + +function getFaviconPath() { + const branding = readBranding(); + if (!branding.faviconFile) return null; + const filePath = path.join(BRANDING_DIR, branding.faviconFile); + if (!fs.existsSync(filePath)) return null; + return { filePath, mime: branding.faviconMime || 'image/png' }; +} + +function resetBranding() { + clearFavicon(); + return writeBranding({ ...DEFAULTS }); +} + +module.exports = { + readBranding, + writeBranding, + publicPayload, + updateBranding, + saveFaviconFromBase64, + clearFavicon, + getFaviconPath, + resetBranding, + DEFAULTS, +}; diff --git a/Structures/DashboardGroupStore.js b/Structures/DashboardGroupStore.js new file mode 100644 index 0000000..76175ec --- /dev/null +++ b/Structures/DashboardGroupStore.js @@ -0,0 +1,202 @@ +// SupportBot | Emerald Services +const fs = require("fs"); +const path = require("path"); +const { fullPermissions, normalizePermissions } = require("./DashboardPermissions.js"); + +const GROUPS_FILE = path.join(__dirname, "../Data/dashboard-groups.json"); + +const DEFAULT_GROUPS = [ + { + id: "owner", + name: "Owner", + description: "Full system owner access to all features and configs", + color: "#10B981", // Emerald Green + discordRoleId: null, + isSystem: true, + permissions: fullPermissions(), + }, + { + id: "admin", + name: "Admin", + description: "Full administrative access including user and group management", + color: "#3B82F6", // Blue + discordRoleId: null, + isSystem: true, + permissions: fullPermissions(false, false), + }, + { + id: "support", + name: "Support Staff", + description: "Access to Overview, Live Tickets, and Transcripts", + color: "#8B5CF6", // Purple + discordRoleId: null, + isSystem: false, + permissions: { + overview: true, + tickets: true, + logs: false, + transcripts: true, + settings: { view: false, update: false }, + configs: { + supportbot: { view: false, edit: false }, + "ticket-panel": { view: false, edit: false }, + commands: { view: false, edit: false }, + messages: { view: false, edit: false }, + "supportbot-ai": { view: false, edit: false }, + api: { view: false, edit: false }, + }, + users: { view: false, manage: false }, + }, + }, + { + id: "viewer", + name: "Viewer", + description: "Read-only access to dashboard and logs", + color: "#6B7280", // Gray + discordRoleId: null, + isSystem: false, + permissions: { + overview: true, + tickets: true, + logs: true, + transcripts: true, + settings: { view: true, update: false }, + configs: { + supportbot: { view: true, edit: false }, + "ticket-panel": { view: true, edit: false }, + commands: { view: true, edit: false }, + messages: { view: true, edit: false }, + "supportbot-ai": { view: true, edit: false }, + api: { view: true, edit: false }, + }, + users: { view: false, manage: false }, + }, + }, +]; + +function ensureStorage() { + const dir = path.dirname(GROUPS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (!fs.existsSync(GROUPS_FILE)) { + const initialData = { + version: 1, + groups: DEFAULT_GROUPS, + }; + fs.writeFileSync(GROUPS_FILE, JSON.stringify(initialData, null, 2), "utf8"); + } +} + +function loadStore() { + ensureStorage(); + try { + const raw = fs.readFileSync(GROUPS_FILE, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.groups)) { + return parsed.groups; + } + } catch (err) { + console.error("[GroupStore] Error loading groups file:", err.message); + } + return DEFAULT_GROUPS; +} + +function saveStore(groups) { + ensureStorage(); + const payload = { + version: 1, + groups, + updatedAt: Date.now(), + }; + fs.writeFileSync(GROUPS_FILE, JSON.stringify(payload, null, 2), "utf8"); +} + +function listGroups() { + return loadStore(); +} + +function getGroup(id) { + const groups = loadStore(); + return groups.find((g) => g.id === id) || null; +} + +function createGroup(payload = {}) { + const groups = loadStore(); + const name = (payload.name || "New Group").trim(); + const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "_"); + let newId = slug || `group_${Date.now()}`; + + let counter = 1; + while (groups.some((g) => g.id === newId)) { + newId = `${slug}_${counter++}`; + } + + const newGroup = { + id: newId, + name, + description: (payload.description || "").trim(), + color: payload.color || "#10B981", + discordRoleId: payload.discordRoleId ? String(payload.discordRoleId).trim() : null, + isSystem: false, + permissions: normalizePermissions(payload.permissions), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + groups.push(newGroup); + saveStore(groups); + return newGroup; +} + +function updateGroup(id, payload = {}) { + if (id === "owner") { + throw new Error('System group "Owner" cannot be modified.'); + } + const groups = loadStore(); + const index = groups.findIndex((g) => g.id === id); + if (index === -1) { + throw new Error(`Group with ID "${id}" not found.`); + } + + const existing = groups[index]; + const updatedGroup = { + ...existing, + name: payload.name !== undefined ? payload.name.trim() : existing.name, + description: payload.description !== undefined ? payload.description.trim() : existing.description, + color: payload.color || existing.color, + discordRoleId: payload.discordRoleId !== undefined ? (payload.discordRoleId ? String(payload.discordRoleId).trim() : null) : existing.discordRoleId, + permissions: payload.permissions ? normalizePermissions(payload.permissions) : existing.permissions, + updatedAt: Date.now(), + }; + + groups[index] = updatedGroup; + saveStore(groups); + return updatedGroup; +} + +function deleteGroup(id) { + const groups = loadStore(); + const group = groups.find((g) => g.id === id); + if (!group) { + throw new Error(`Group with ID "${id}" not found.`); + } + + if (group.isSystem) { + throw new Error(`System group "${group.name}" cannot be deleted.`); + } + + const filtered = groups.filter((g) => g.id !== id); + saveStore(filtered); + return true; +} + +module.exports = { + listGroups, + getGroup, + createGroup, + updateGroup, + deleteGroup, + DEFAULT_GROUPS, +}; diff --git a/Structures/DashboardPermissions.js b/Structures/DashboardPermissions.js new file mode 100644 index 0000000..4b6ae2b --- /dev/null +++ b/Structures/DashboardPermissions.js @@ -0,0 +1,150 @@ +const CONFIG_FILES = [ + "supportbot", + "ticket-panel", + "commands", + "messages", + "supportbot-ai", + "api", +]; + +const ROLES = ["owner", "admin", "moderator", "editor", "viewer", "custom"]; + +function configMap(view, edit) { + const configs = {}; + for (const file of CONFIG_FILES) { + configs[file] = { view: Boolean(view), edit: Boolean(edit) }; + } + return configs; +} + +function fullPermissions(rawYaml = true, userManage = true) { + return { + overview: true, + tickets: true, + logs: true, + transcripts: true, + rawYaml: Boolean(rawYaml), + settings: { view: true, update: true }, + configs: configMap(true, true), + users: { view: true, manage: Boolean(userManage) }, + }; +} + +function permissionsFromRole(role, customPermissions) { + switch (role) { + case "owner": + return fullPermissions(true, true); + case "admin": + return fullPermissions(false, false); + case "moderator": + return { + overview: true, + tickets: true, + logs: true, + transcripts: true, + rawYaml: false, + settings: { view: true, update: false }, + configs: configMap(true, false), + users: { view: false, manage: false }, + }; + case "editor": + return { + overview: true, + tickets: true, + logs: true, + transcripts: false, + rawYaml: false, + settings: { view: true, update: false }, + configs: configMap(true, true), + users: { view: false, manage: false }, + }; + case "viewer": + return { + overview: true, + tickets: true, + logs: true, + transcripts: true, + rawYaml: false, + settings: { view: true, update: false }, + configs: configMap(true, false), + users: { view: false, manage: false }, + }; + case "custom": + return normalizePermissions(customPermissions); + default: + return permissionsFromRole("viewer"); + } +} + +function normalizePermissions(input) { + const base = permissionsFromRole("viewer"); + if (!input || typeof input !== "object") return base; + + return { + overview: input.overview ?? base.overview, + tickets: input.tickets ?? input.overview ?? base.tickets, + logs: input.logs ?? base.logs, + transcripts: input.transcripts ?? base.transcripts, + rawYaml: Boolean(input.rawYaml ?? input.configs?.rawYaml ?? base.rawYaml), + settings: { + view: input.settings?.view ?? base.settings.view, + update: input.settings?.update ?? base.settings.update, + }, + configs: CONFIG_FILES.reduce((acc, file) => { + acc[file] = { + view: input.configs?.[file]?.view ?? base.configs[file].view, + edit: input.configs?.[file]?.edit ?? base.configs[file].edit, + }; + return acc; + }, {}), + users: { + view: input.users?.view ?? base.users.view, + manage: input.users?.manage ?? base.users.manage, + }, + }; +} + +function hasPermission(permissions, check) { + if (!permissions) return false; + + if (check === "overview") return Boolean(permissions.overview); + if (check === "tickets") return Boolean(permissions.tickets ?? permissions.overview); + if (check === "logs") return Boolean(permissions.logs); + if (check === "transcripts") return Boolean(permissions.transcripts); + if (check === "configs.raw_yaml" || check === "configs.rawYaml" || check === "rawYaml") { + return Boolean(permissions.rawYaml ?? permissions.configs?.rawYaml ?? false); + } + if (check === "settings.view") return Boolean(permissions.settings?.view); + if (check === "settings.update") return Boolean(permissions.settings?.update); + if (check === "users.view") return Boolean(permissions.users?.view); + if (check === "users.manage") return Boolean(permissions.users?.manage); + + const configView = check.match(/^configs\.([a-z0-9-]+)\.view$/); + if (configView) { + const file = configView[1]; + return Boolean(permissions.configs?.[file]?.view); + } + + const configEdit = check.match(/^configs\.([a-z0-9-]+)\.edit$/); + if (configEdit) { + const file = configEdit[1]; + return Boolean(permissions.configs?.[file]?.edit); + } + + return false; +} + +function canAccessConfig(permissions, file, mode = "view") { + if (!CONFIG_FILES.includes(file)) return false; + return hasPermission(permissions, `configs.${file}.${mode}`); +} + +module.exports = { + CONFIG_FILES, + ROLES, + fullPermissions, + permissionsFromRole, + normalizePermissions, + hasPermission, + canAccessConfig, +}; diff --git a/Structures/DashboardSession.js b/Structures/DashboardSession.js new file mode 100644 index 0000000..a6e12d8 --- /dev/null +++ b/Structures/DashboardSession.js @@ -0,0 +1,75 @@ +const crypto = require("crypto"); + +const SESSION_COOKIE = "sb_dashboard"; +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +function signSession(payload, secret) { + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const sig = crypto + .createHmac("sha256", secret) + .update(body) + .digest("base64url"); + return `${body}.${sig}`; +} + +function verifySession(token, secret) { + if (!token || typeof token !== "string") return null; + const dot = token.lastIndexOf("."); + if (dot <= 0) return null; + + const body = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = crypto + .createHmac("sha256", secret) + .update(body) + .digest("base64url"); + + if (sig.length !== expected.length) return null; + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + return null; + } + + try { + const payload = JSON.parse( + Buffer.from(body, "base64url").toString("utf8"), + ); + if (!payload?.userId || !payload?.exp) return null; + if (Date.now() > payload.exp) return null; + return payload; + } catch { + return null; + } +} + +function createSessionPayload(user) { + return { + userId: user.id, + username: user.username, + globalName: user.global_name || null, + avatar: user.avatar || null, + exp: Date.now() + SESSION_MAX_AGE_MS, + }; +} + +function sessionCookieOptions(maxAgeMs = SESSION_MAX_AGE_MS, opts = {}) { + const secure = + opts.secure !== undefined + ? Boolean(opts.secure) + : process.env.NODE_ENV === "production"; + return { + httpOnly: true, + secure, + sameSite: "lax", + maxAge: maxAgeMs, + path: "/", + }; +} + +module.exports = { + SESSION_COOKIE, + SESSION_MAX_AGE_MS, + signSession, + verifySession, + createSessionPayload, + sessionCookieOptions, +}; diff --git a/Structures/DashboardSetup.js b/Structures/DashboardSetup.js new file mode 100644 index 0000000..0d981b7 --- /dev/null +++ b/Structures/DashboardSetup.js @@ -0,0 +1,510 @@ +const fs = require("fs"); +const path = require("path"); +const axios = require("axios"); +const YAML = require("yaml"); +const configStore = require("./ConfigStore.js"); +const { CALLBACK_PATH } = require("./DiscordOAuth.js"); + +const DISCORD_API = "https://discord.com/api/v10"; +const CONFIG_DIR = path.join(__dirname, "../Configs"); +const API_FILE = path.join(CONFIG_DIR, "api.yml"); +const SUPPORTBOT_FILE = path.join(CONFIG_DIR, "supportbot.yml"); + +const PLACEHOLDER_PATTERNS = [ + /DISCORD_CLIENT_ID/i, + /DISCORD_CLIENT_SECRET/i, + /YOUR_DISCORD_USER_ID/i, + /CHANGE_ME/i, + /BOT_TOKEN/i, + /PUT_RANDOM_STRING/i, +]; + +function isEmpty(value) { + if (value == null) return true; + if (typeof value === "boolean") return false; + if (typeof value === "number") return false; + return String(value).trim() === ""; +} + +function isPlaceholder(value) { + if (isEmpty(value)) return true; + const s = String(value).trim(); + return PLACEHOLDER_PATTERNS.some((p) => p.test(s)); +} + +function isDiscordSnowflake(value) { + return /^\d{17,20}$/.test(String(value).trim()); +} + +function isValidBotToken(value) { + if (isPlaceholder(value)) return false; + const s = String(value).trim(); + return s.length >= 50 && s.includes("."); +} + +function isValidSecretKey(value) { + if (isPlaceholder(value)) return false; + return String(value).trim().length >= 16; +} + +function readApiRoot() { + if (!fs.existsSync(API_FILE)) return null; + try { + const raw = YAML.parse(fs.readFileSync(API_FILE, "utf8")); + return raw?.API ?? raw ?? null; + } catch { + return null; + } +} + +function readSupportbotRoot() { + if (!fs.existsSync(SUPPORTBOT_FILE)) return {}; + try { + return YAML.parse(fs.readFileSync(SUPPORTBOT_FILE, "utf8")) || {}; + } catch { + return {}; + } +} + +function getOwnerIds(oauth) { + const ids = []; + for (const id of oauth?.OwnerUserIds || []) { + const s = String(id).trim(); + if (s) ids.push(s); + } + if (ids.length === 0) { + for (const id of oauth?.AllowedUserIds || []) { + const s = String(id).trim(); + if (s) ids.push(s); + } + } + return ids; +} + +function buildSteps(apiRoot, supportbot) { + const oauth = apiRoot?.OAuth || {}; + const token = supportbot?.General?.Token; + const dbConfig = supportbot?.Database || {}; + + return [ + { + id: "botToken", + title: "Bot token", + description: "From the Discord Developer Portal → your application → Bot → Reset Token.", + complete: isValidBotToken(token), + }, + { + id: "database", + title: "Database storage", + description: "Choose SQLite or MySQL database engine for storing bot data.", + complete: Boolean(dbConfig.Driver), + }, + { + id: "secretKey", + title: "Dashboard session secret", + description: "Random string used to sign login cookies. Keep it private.", + complete: isValidSecretKey(apiRoot?.SecretKey), + }, + { + id: "oauth", + title: "Discord OAuth app", + description: "Same application as your bot — Client ID and Client Secret from OAuth2.", + complete: + !isPlaceholder(oauth.ClientId) && + !isPlaceholder(oauth.ClientSecret) && + isDiscordSnowflake(oauth.ClientId), + }, + { + id: "redirectUri", + title: "OAuth redirect URL", + description: `Add this exact URL under OAuth2 → Redirects in the Discord portal.`, + complete: isValidRedirectUriConfigured(oauth.RedirectUri), + }, + { + id: "owners", + title: "Dashboard owners", + description: "Discord user IDs that can sign in and manage the dashboard (at least one).", + complete: getOwnerIds(oauth).length > 0 && getOwnerIds(oauth).every(isDiscordSnowflake), + }, + { + id: "emeraldApi", + title: "Emerald API", + description: "Optional. Used for the one-click addon installer.", + complete: true, + }, + ]; +} + +function isValidRedirectUriConfigured(uri) { + if (isPlaceholder(uri)) return false; + try { + const u = new URL(String(uri).trim()); + if (!["http:", "https:"].includes(u.protocol)) return false; + return u.pathname.replace(/\/$/, "") === CALLBACK_PATH.replace(/\/$/, ""); + } catch { + return false; + } +} + +function isSetupComplete() { + const apiRoot = readApiRoot(); + if (!apiRoot?.Enabled) return false; + const oauth = apiRoot.OAuth || {}; + if (!oauth.Enabled) return false; + const steps = buildSteps(apiRoot, readSupportbotRoot()); + return steps.every((s) => s.complete); +} + +function getSetupStatus(req) { + const apiRoot = readApiRoot() || {}; + const steps = buildSteps(apiRoot, readSupportbotRoot()); + const complete = steps.every((s) => s.complete); + + let suggestedRedirectUri = null; + if (req) { + const proto = + req.get("x-forwarded-proto")?.split(",")[0]?.trim() || + req.protocol || + "http"; + const host = req.get("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host"); + if (host) { + suggestedRedirectUri = `${proto}://${host}${CALLBACK_PATH}`; + } + } + if (!suggestedRedirectUri) { + suggestedRedirectUri = `http://localhost:${apiRoot.Port || 3000}${CALLBACK_PATH}`; + } + + return { + complete, + steps, + suggestedRedirectUri, + oauthCallbackPath: CALLBACK_PATH, + }; +} + +async function validateBotToken(token) { + const trimmed = String(token || "").trim(); + if (!isValidBotToken(trimmed)) { + return { ok: false, error: "Enter a valid bot token from the Discord Developer Portal." }; + } + + try { + const [userRes, appRes] = await Promise.all([ + axios.get(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bot ${trimmed}` }, + timeout: 12_000, + }), + axios.get(`${DISCORD_API}/oauth2/applications/@me`, { + headers: { Authorization: `Bot ${trimmed}` }, + timeout: 12_000, + }), + ]); + + return { + ok: true, + bot: { + id: userRes.data.id, + username: userRes.data.username, + discriminator: userRes.data.discriminator, + }, + applicationId: String(appRes.data.id), + }; + } catch (err) { + const status = err.response?.status; + if (status === 401) { + return { ok: false, error: "Discord rejected this bot token (401 Unauthorized)." }; + } + return { + ok: false, + error: err.response?.data?.message || err.message || "Could not verify bot token.", + }; + } +} + +async function validateOAuthCredentials(clientId, clientSecret, botToken) { + const id = String(clientId || "").trim(); + const secret = String(clientSecret || "").trim(); + + if (!isDiscordSnowflake(id)) { + return { ok: false, error: "Client ID must be a numeric Discord application ID." }; + } + if (isPlaceholder(secret) || secret.length < 20) { + return { ok: false, error: "Enter the OAuth Client Secret from the Discord portal." }; + } + + if (botToken) { + const botCheck = await validateBotToken(botToken); + if (botCheck.ok && botCheck.applicationId && botCheck.applicationId !== id) { + return { + ok: false, + error: `Client ID does not match the bot application (${botCheck.applicationId}). Use the same app's credentials.`, + }; + } + } + + try { + await axios.post( + `${DISCORD_API}/oauth2/token`, + new URLSearchParams({ grant_type: "client_credentials", scope: "identify" }), + { + auth: { username: id, password: secret }, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: 12_000, + }, + ); + return { ok: true, clientId: id }; + } catch (err) { + const status = err.response?.status; + if (botToken) { + const botCheck = await validateBotToken(botToken); + if (botCheck.ok && botCheck.applicationId === id) { + return { + ok: true, + clientId: id, + warning: + "Could not verify the client secret with Discord (token endpoint). ID matches your bot — double-check the secret in the portal.", + }; + } + } + if (status === 401 || status === 400) { + return { ok: false, error: "Invalid Client ID or Client Secret." }; + } + return { + ok: false, + error: err.response?.data?.error_description || err.message || "OAuth validation failed.", + }; + } +} + +function validateRedirectUri(uri) { + const trimmed = String(uri || "").trim(); + if (!isValidRedirectUriConfigured(trimmed)) { + return { + ok: false, + error: `Redirect URL must end with ${CALLBACK_PATH} and use http or https.`, + }; + } + return { ok: true, redirectUri: trimmed }; +} + +async function validateOwnerUserIds(ownerIds, botToken) { + const ids = (Array.isArray(ownerIds) ? ownerIds : [ownerIds]) + .map((id) => String(id).trim()) + .filter(Boolean); + + if (ids.length === 0) { + return { ok: false, error: "Add at least one Discord user ID." }; + } + + const invalid = ids.filter((id) => !isDiscordSnowflake(id)); + if (invalid.length) { + return { ok: false, error: `Invalid user ID: ${invalid[0]}` }; + } + + if (!botToken || !isValidBotToken(botToken)) { + return { ok: true, ownerUserIds: ids, warning: "User IDs format OK (bot token not verified yet)." }; + } + + const users = []; + try { + for (const id of ids) { + const res = await axios.get(`${DISCORD_API}/users/${id}`, { + headers: { Authorization: `Bot ${String(botToken).trim()}` }, + timeout: 12_000, + }); + users.push({ + id: res.data.id, + username: res.data.username, + globalName: res.data.global_name || null, + }); + } + return { ok: true, ownerUserIds: ids, users }; + } catch (err) { + if (err.response?.status === 404) { + return { ok: false, error: "One or more user IDs were not found. Check Developer Mode → Copy User ID." }; + } + if (err.response?.status === 403) { + return { + ok: false, + error: "Bot could not look up users. Ensure the bot is in a server with those users, or verify IDs manually.", + }; + } + return { ok: false, error: err.message || "Failed to verify owner user IDs." }; + } +} + +function validateSecretKey(secretKey) { + if (!isValidSecretKey(secretKey)) { + return { ok: false, error: "Use at least 16 characters (not CHANGE_ME)." }; + } + return { ok: true }; +} + +async function validateDatabase(dbDriver, dbHost, dbPort, dbUser, dbPassword, dbDatabase) { + const driver = String(dbDriver || "sqlite").toLowerCase(); + if (driver === "mysql" || driver === "mariadb") { + const host = String(dbHost || "").trim(); + const port = Number(dbPort) || 3306; + const user = String(dbUser || "").trim(); + const password = String(dbPassword || ""); + const database = String(dbDatabase || "").trim(); + + if (isEmpty(host)) return { ok: false, error: "MySQL Host is required." }; + if (isEmpty(user)) return { ok: false, error: "MySQL User is required." }; + if (isEmpty(database)) return { ok: false, error: "MySQL Database Name is required." }; + + try { + const mysql = require("mysql2/promise"); + const connection = await mysql.createConnection({ + host, + port, + user, + password, + database, + connectTimeout: 5000, + }); + await connection.end(); + return { ok: true, driver: "mysql" }; + } catch (err) { + return { + ok: false, + error: `MySQL Connection Failed: ${err.message || err.code || "Could not connect to database host."}`, + }; + } + } + + return { ok: true, driver: "sqlite" }; +} + +async function validateSetupPayload(payload) { + const results = {}; + + if (payload.botToken != null) { + results.botToken = await validateBotToken(payload.botToken); + } + if (payload.dbDriver != null) { + results.database = await validateDatabase( + payload.dbDriver, + payload.dbHost, + payload.dbPort, + payload.dbUser, + payload.dbPassword, + payload.dbDatabase, + ); + } + if (payload.secretKey != null) { + results.secretKey = validateSecretKey(payload.secretKey); + } + if (payload.clientId != null && payload.clientSecret != null) { + results.oauth = await validateOAuthCredentials( + payload.clientId, + payload.clientSecret, + payload.botToken, + ); + } + if (payload.redirectUri != null) { + results.redirectUri = validateRedirectUri(payload.redirectUri); + } + if (payload.ownerUserIds != null) { + results.owners = await validateOwnerUserIds(payload.ownerUserIds, payload.botToken); + } + if (payload.emeraldApiKey != null) { + const key = String(payload.emeraldApiKey).trim(); + if (key === "") { + results.emeraldApi = { ok: true }; + } else if (key.length < 10) { + results.emeraldApi = { ok: false, error: "Invalid API key." }; + } else { + results.emeraldApi = { ok: true }; + } + } + + const allOk = Object.values(results).every((r) => r?.ok); + return { ok: allOk, results }; +} + +function ensureApiFile() { + if (fs.existsSync(API_FILE)) return; + const example = path.join(CONFIG_DIR, "api.yml.example"); + if (fs.existsSync(example)) { + fs.copyFileSync(example, API_FILE); + configStore.reloadFile("api"); + } else { + throw new Error("Configs/api.yml is missing. Copy Configs/api.yml.example to api.yml."); + } +} + +function applySetup(payload) { + ensureApiFile(); + + const ownerIds = (Array.isArray(payload.ownerUserIds) + ? payload.ownerUserIds + : String(payload.ownerUserIds || "").split(/[\s,]+/) + ) + .map((id) => String(id).trim()) + .filter(Boolean); + + const apiDoc = YAML.parseDocument(fs.readFileSync(API_FILE, "utf8")); + if (!apiDoc.get("API")) apiDoc.set("API", {}); + + apiDoc.setIn(["API", "Enabled"], true); + apiDoc.setIn(["API", "SecretKey"], String(payload.secretKey).trim()); + if (payload.emeraldApiKey != null) { + apiDoc.setIn(["API", "EmeraldAPIKey"], String(payload.emeraldApiKey).trim()); + } + if (payload.port != null && payload.port !== "") { + apiDoc.setIn(["API", "Port"], Number(payload.port) || 3000); + } + if (!apiDoc.getIn(["API", "OAuth"])) apiDoc.setIn(["API", "OAuth"], {}); + + apiDoc.setIn(["API", "OAuth", "Enabled"], true); + apiDoc.setIn(["API", "OAuth", "ClientId"], String(payload.clientId).trim()); + apiDoc.setIn(["API", "OAuth", "ClientSecret"], String(payload.clientSecret).trim()); + apiDoc.setIn(["API", "OAuth", "RedirectUri"], String(payload.redirectUri).trim()); + apiDoc.setIn(["API", "OAuth", "OwnerUserIds"], ownerIds); + apiDoc.setIn(["API", "OAuth", "AllowedUserIds"], []); + + fs.writeFileSync(API_FILE, apiDoc.toString()); + + const sbDoc = YAML.parseDocument(fs.readFileSync(SUPPORTBOT_FILE, "utf8")); + if (payload.botToken != null && String(payload.botToken).trim() !== "") { + sbDoc.setIn(["General", "Token"], String(payload.botToken).trim()); + } + + if (payload.dbDriver != null) { + const driver = String(payload.dbDriver).toLowerCase() === "mysql" ? "mysql" : "sqlite"; + sbDoc.setIn(["Database", "Driver"], driver); + if (driver === "mysql") { + sbDoc.setIn(["Database", "MySQL", "Host"], String(payload.dbHost || "127.0.0.1").trim()); + sbDoc.setIn(["Database", "MySQL", "Port"], Number(payload.dbPort) || 3306); + sbDoc.setIn(["Database", "MySQL", "User"], String(payload.dbUser || "").trim()); + sbDoc.setIn(["Database", "MySQL", "Password"], String(payload.dbPassword || "")); + sbDoc.setIn(["Database", "MySQL", "Database"], String(payload.dbDatabase || "").trim()); + } else { + sbDoc.setIn(["Database", "SQLite", "File"], "./Data/supportbot.db"); + } + } + + fs.writeFileSync(SUPPORTBOT_FILE, sbDoc.toString()); + + configStore.reloadFile("api"); + configStore.reloadFile("supportbot"); + + return { + ownerUserIds: ownerIds, + api: configStore.api?.API || configStore.api, + }; +} + +module.exports = { + isSetupComplete, + getSetupStatus, + validateBotToken, + validateOAuthCredentials, + validateRedirectUri, + validateOwnerUserIds, + validateSecretKey, + validateSetupPayload, + applySetup, +}; diff --git a/Structures/DashboardUserStore.js b/Structures/DashboardUserStore.js new file mode 100644 index 0000000..2a4c9ef --- /dev/null +++ b/Structures/DashboardUserStore.js @@ -0,0 +1,311 @@ +const fs = require("fs"); +const path = require("path"); +const { + CONFIG_FILES, + ROLES, + fullPermissions, + permissionsFromRole, + normalizePermissions, + hasPermission, + canAccessConfig, +} = require("./DashboardPermissions.js"); +const { discordAvatarUrl } = require("./DiscordOAuth.js"); + +const STORE_PATH = path.join(__dirname, "../Data/dashboard-users.json"); + +function ensureDataDir() { + const dir = path.dirname(STORE_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function emptyStore() { + return { version: 1, users: {} }; +} + +function readStore() { + ensureDataDir(); + if (!fs.existsSync(STORE_PATH)) return emptyStore(); + try { + const raw = JSON.parse(fs.readFileSync(STORE_PATH, "utf8")); + if (!raw.users || typeof raw.users !== "object") return emptyStore(); + return raw; + } catch { + return emptyStore(); + } +} + +function writeStore(store) { + ensureDataDir(); + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +function getOwnerIds(oauth) { + const owners = new Set(); + for (const id of oauth?.OwnerUserIds || []) owners.add(String(id)); + for (const id of oauth?.AllowedUserIds || []) owners.add(String(id)); + return owners; +} + +function isYamlOwner(userId, oauth) { + return getOwnerIds(oauth).has(String(userId)); +} + +function bootstrapFromOAuth(oauth) { + const store = readStore(); + const owners = getOwnerIds(oauth); + let changed = false; + + for (const id of owners) { + if (!store.users[id]) { + store.users[id] = { + id, + username: null, + globalName: null, + avatar: null, + role: "owner", + permissions: null, + enabled: true, + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: "system", + }; + changed = true; + } + } + + if (changed) writeStore(store); + return store; +} + +function syncDiscordProfile(userId, profile) { + const store = readStore(); + const entry = store.users[userId]; + if (!entry) return; + + entry.username = profile.username ?? entry.username; + entry.globalName = profile.globalName ?? entry.globalName; + entry.avatar = profile.avatar ?? entry.avatar; + entry.updatedAt = Date.now(); + writeStore(store); +} + +const { getGroup } = require("./DashboardGroupStore.js"); + +function resolvePermissions(entry, oauth, userId) { + if (isYamlOwner(userId, oauth)) { + return { permissions: fullPermissions(), role: "owner", isOwner: true, groupId: "owner" }; + } + if (!entry?.enabled) return null; + + const groupId = entry.groupId || entry.role || "support"; + const group = getGroup(groupId); + + if (entry.role === "custom" && entry.permissions) { + return { permissions: normalizePermissions(entry.permissions), role: "custom", isOwner: false, groupId }; + } + + const permissions = group ? group.permissions : permissionsFromRole(entry.role || "viewer"); + return { permissions, role: entry.role || groupId, isOwner: false, groupId }; +} + +function getAccess(userId, oauth) { + bootstrapFromOAuth(oauth); + const store = readStore(); + const id = String(userId); + + if (isYamlOwner(id, oauth)) { + const entry = store.users[id]; + return { + entry: entry || { id, role: "owner" }, + permissions: fullPermissions(), + role: "owner", + isOwner: true, + yamlOwner: true, + }; + } + + const entry = store.users[id]; + const resolved = resolvePermissions(entry, oauth, id); + if (!resolved) return null; + + return { + entry, + permissions: resolved.permissions, + role: resolved.role, + isOwner: false, + yamlOwner: false, + }; +} + +function canLogin(userId, oauth) { + return Boolean(getAccess(userId, oauth)); +} + +function formatPublicUser(entry, access, discordProfile) { + const id = String(entry?.id || discordProfile?.id); + const avatarHash = entry?.avatar ?? discordProfile?.avatar ?? null; + + return { + id, + username: entry?.username || discordProfile?.username || "Unknown", + globalName: entry?.globalName ?? discordProfile?.global_name ?? null, + avatar: avatarHash + ? discordAvatarUrl({ id, avatar: avatarHash }) + : discordProfile + ? discordAvatarUrl(discordProfile) + : discordAvatarUrl({ id, avatar: null }), + role: access.role, + isOwner: access.isOwner, + permissions: access.permissions, + }; +} + +function listUsers(oauth, discordClient) { + bootstrapFromOAuth(oauth); + const store = readStore(); + const yamlOwners = getOwnerIds(oauth); + + let updated = false; + + const usersList = Object.values(store.users).map((entry) => { + if (discordClient?.users?.cache) { + const cached = discordClient.users.cache.get(String(entry.id)); + if (cached && (!entry.username || !entry.avatar)) { + entry.username = cached.username || entry.username; + entry.globalName = cached.globalName || cached.username || entry.globalName; + entry.avatar = cached.avatar || entry.avatar; + updated = true; + } + } + + const access = resolvePermissions(entry, oauth, entry.id) || { + permissions: permissionsFromRole("viewer"), + role: entry.role, + isOwner: false, + }; + + return { + id: entry.id, + username: entry.username || (yamlOwners.has(String(entry.id)) ? "Owner" : "User"), + globalName: entry.globalName || entry.username || null, + avatar: entry.avatar + ? discordAvatarUrl({ id: entry.id, avatar: entry.avatar }) + : discordAvatarUrl({ id: entry.id, avatar: null }), + role: yamlOwners.has(String(entry.id)) ? "owner" : entry.role || entry.groupId || "support", + groupId: entry.groupId || entry.role || "support", + permissions: + entry.role === "custom" + ? normalizePermissions(entry.permissions) + : access.permissions, + enabled: entry.enabled !== false, + isOwner: yamlOwners.has(String(entry.id)), + yamlOwner: yamlOwners.has(String(entry.id)), + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + }; + }); + + if (updated) { + writeStore(store); + } + + return usersList; +} + +function addUser(payload, actorId, oauth) { + const id = String(payload.id || "").trim(); + if (!/^\d{17,20}$/.test(id)) { + throw new Error("Invalid Discord user ID"); + } + + const role = payload.role || payload.groupId || "support"; + const groupId = payload.groupId || payload.role || "support"; + + const store = readStore(); + if (store.users[id]) throw new Error("User already exists"); + + store.users[id] = { + id, + username: payload.username || null, + globalName: payload.globalName || null, + avatar: null, + role, + groupId, + permissions: + role === "custom" ? normalizePermissions(payload.permissions) : null, + enabled: payload.enabled !== false, + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: String(actorId), + }; + + writeStore(store); + return listUsers(oauth).find((u) => u.id === id); +} + +function updateUser(id, payload, actorId, oauth) { + const store = readStore(); + const entry = store.users[String(id)]; + if (!entry) throw new Error("User not found"); + + if (isYamlOwner(id, oauth) && payload.enabled === false) { + throw new Error("Cannot disable an owner defined in api.yml"); + } + + if (payload.groupId) { + entry.groupId = payload.groupId; + if (!payload.role) entry.role = payload.groupId; + } + + if (payload.role) { + entry.role = payload.role; + if (!payload.groupId) entry.groupId = payload.role; + } + + if (payload.enabled !== undefined) entry.enabled = Boolean(payload.enabled); + if (payload.username !== undefined) entry.username = payload.username; + if (payload.globalName !== undefined) entry.globalName = payload.globalName; + + if (payload.permissions !== undefined) { + entry.permissions = normalizePermissions(payload.permissions); + if (entry.role !== "custom") entry.role = "custom"; + } + + if (entry.role === "custom" && payload.permissions === undefined && payload.role !== "custom") { + entry.permissions = null; + } + + entry.updatedAt = Date.now(); + writeStore(store); + return listUsers(oauth).find((u) => u.id === String(id)); +} + +function removeUser(id, oauth) { + if (isYamlOwner(id, oauth)) { + throw new Error("Remove this user from OwnerUserIds in api.yml instead"); + } + + const store = readStore(); + if (!store.users[String(id)]) throw new Error("User not found"); + delete store.users[String(id)]; + writeStore(store); +} + +module.exports = { + CONFIG_FILES, + STORE_PATH, + getOwnerIds, + isYamlOwner, + bootstrapFromOAuth, + syncDiscordProfile, + getAccess, + canLogin, + formatPublicUser, + listUsers, + addUser, + updateUser, + removeUser, + hasPermission, + canAccessConfig, + ROLES, +}; diff --git a/Structures/Database.js b/Structures/Database.js index 3ec4803..8b70d95 100644 --- a/Structures/Database.js +++ b/Structures/Database.js @@ -1,187 +1,797 @@ -// SupportBot | Emerald Services -const Database = require("better-sqlite3"); +// SupportBot | Unified SQL Database Adapter (MySQL/MariaDB & SQLite) +const fs = require("fs"); const path = require("path"); +const yaml = require("js-yaml"); -const db = new Database(path.join(__dirname, "../Data/supportbot.db")); +const CONFIG_PATH = path.join(__dirname, "../Configs/supportbot.yml"); +const LEGACY_JSON_PATH = path.join(__dirname, "../Data/supportbot.json"); +const SQLITE_DB_PATH = path.join(__dirname, "../Data/supportbot.db"); -db.prepare( - ` - CREATE TABLE IF NOT EXISTS suggestions ( - thread_id TEXT PRIMARY KEY, - author_id TEXT, - text TEXT, - status TEXT DEFAULT 'Created' - ) -`, -).run(); - -db.prepare( - ` - CREATE TABLE IF NOT EXISTS profiles ( - user_id TEXT PRIMARY KEY, - bio TEXT, - timezone TEXT, - clockedIn INTEGER DEFAULT 0 - ) -`, -).run(); - -db.prepare( - ` - CREATE TABLE IF NOT EXISTS tickets ( - ticket_id TEXT PRIMARY KEY, - user_id TEXT, - status TEXT DEFAULT 'open', - created_at INTEGER, - updated_at INTEGER, - subject TEXT, - description TEXT - ) -`, -).run(); - -// Safe column upgrades -const safeAlter = (sql) => { - try { - db.prepare(sql).run(); - } catch (e) { - if (!String(e).includes("duplicate column name")) throw e; +// Load config +let dbConfig = { Driver: "sqlite", MySQL: {}, SQLite: { File: "./Data/supportbot.db" } }; +try { + if (fs.existsSync(CONFIG_PATH)) { + const rawYaml = fs.readFileSync(CONFIG_PATH, "utf8"); + const parsed = yaml.load(rawYaml); + if (parsed && parsed.Database) { + dbConfig = parsed.Database; + } } +} catch (e) { + console.warn("[Database] Warning: Failed to parse Database section in supportbot.yml, defaulting to SQLite."); +} + +const driver = String(dbConfig.Driver || "sqlite").toLowerCase(); +const isMySQL = driver === "mysql" || driver === "mariadb"; + +let sqliteDb = null; +let mysqlPool = null; +let mysqlReady = false; +const mysqlWriteQueue = []; + +// In-memory state cache for synchronous, high-performance reads +const state = { + tickets: {}, + ticket_panel: [], + ticket_users: {}, + profiles: {}, + settings: {}, + blacklisted_users: {}, + suggestions: {}, }; -safeAlter(`ALTER TABLE tickets ADD COLUMN voiceChannelId TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN department TEXT DEFAULT 'general'`); -safeAlter(`ALTER TABLE tickets ADD COLUMN priority TEXT DEFAULT 'medium'`); -safeAlter(`ALTER TABLE tickets ADD COLUMN aiModeEnabled INTEGER DEFAULT 0`); -safeAlter(`ALTER TABLE tickets ADD COLUMN aiChannelId TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN aiType TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN aiEnabledBy TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN aiEnabledAt INTEGER`); -safeAlter(`ALTER TABLE tickets ADD COLUMN questionAnswers TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN ticketName TEXT`); -safeAlter(`ALTER TABLE tickets ADD COLUMN ticketNumber TEXT`); +// ---------------------------------------------------- +// Database Initialization +// ---------------------------------------------------- +function initDatabase() { + if (isMySQL) { + initMySQL(); + } else { + initSQLite(); + } + migrateLegacyJSON(); +} -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ticket_panel ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id TEXT, - channel_id TEXT, - created_at INTEGER - ) -`, -).run(); +function initSQLite() { + const Database = require("better-sqlite3"); + const dbDir = path.dirname(SQLITE_DB_PATH); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } -db.prepare( - ` - CREATE TABLE IF NOT EXISTS ticket_users ( - ticket_id TEXT, - user_id TEXT, - PRIMARY KEY(ticket_id, user_id) - ) -`, -).run(); + sqliteDb = new Database(SQLITE_DB_PATH); + sqliteDb.pragma("journal_mode = WAL"); -db.prepare( - ` - CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY, - data TEXT - ) -`, -).run(); + sqliteDb.exec(` + CREATE TABLE IF NOT EXISTS tickets ( + ticket_id TEXT PRIMARY KEY, + user_id TEXT, + subject TEXT, + description TEXT, + department TEXT, + priority TEXT, + status TEXT, + ai_mode_enabled INTEGER DEFAULT 0, + ai_channel_id TEXT, + ai_type TEXT, + ai_enabled_by TEXT, + ai_enabled_at INTEGER, + question_answers TEXT, + ticket_name TEXT, + ticket_number TEXT, + voice_channel_id TEXT, + created_at INTEGER, + updated_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS ticket_panels ( + id INTEGER PRIMARY KEY, + message_id TEXT, + channel_id TEXT, + created_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS ticket_users ( + ticket_id TEXT, + user_id TEXT, + PRIMARY KEY (ticket_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS profiles ( + user_id TEXT PRIMARY KEY, + bio TEXT DEFAULT '', + timezone TEXT DEFAULT '', + clocked_in INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT + ); + + CREATE TABLE IF NOT EXISTS blacklisted_users ( + user_id TEXT PRIMARY KEY, + reason TEXT, + added_by TEXT, + created_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS suggestions ( + id TEXT PRIMARY KEY, + author_id TEXT, + text TEXT, + status TEXT DEFAULT 'Created', + upvotes TEXT DEFAULT '[]', + downvotes TEXT DEFAULT '[]', + created_at INTEGER + ); + `); + + loadSQLiteState(); + const clr = { reset: "\x1b[0m", gray: "\x1b[90m", green: "\x1b[32m", white: "\x1b[37m" }; + console.log(`${clr.green}✓${clr.reset} ${clr.white}Database Connected${clr.reset} ${clr.gray}to SQLite (${SQLITE_DB_PATH})${clr.reset}`); +} + +function loadSQLiteState() { + const tickets = sqliteDb.prepare("SELECT * FROM tickets").all(); + for (const r of tickets) { + state.tickets[r.ticket_id] = { + ticket_id: r.ticket_id, + user_id: r.user_id, + subject: r.subject || "", + description: r.description || "", + department: r.department || "general", + priority: r.priority || "medium", + status: r.status || "open", + claimedBy: r.claimed_by || null, + claimed_by: r.claimed_by || null, + claimedAt: r.claimed_at || null, + claimed_at: r.claimed_at || null, + aiModeEnabled: r.ai_mode_enabled, + aiChannelId: r.ai_channel_id, + aiType: r.ai_type, + aiEnabledBy: r.ai_enabled_by, + aiEnabledAt: r.ai_enabled_at, + questionAnswers: r.question_answers || "[]", + ticketName: r.ticket_name, + ticketNumber: r.ticket_number, + voiceChannelId: r.voice_channel_id, + created_at: r.created_at, + updated_at: r.updated_at, + }; + } + + const panels = sqliteDb.prepare("SELECT * FROM ticket_panels ORDER BY id ASC").all(); + state.ticket_panel = panels.map((p) => ({ + id: p.id, + message_id: p.message_id, + channel_id: p.channel_id, + created_at: p.created_at, + })); + + const ticketUsers = sqliteDb.prepare("SELECT * FROM ticket_users").all(); + for (const tu of ticketUsers) { + state.ticket_users[tu.ticket_id] = state.ticket_users[tu.ticket_id] || []; + if (!state.ticket_users[tu.ticket_id].includes(tu.user_id)) { + state.ticket_users[tu.ticket_id].push(tu.user_id); + } + } + + const profiles = sqliteDb.prepare("SELECT * FROM profiles").all(); + for (const p of profiles) { + state.profiles[p.user_id] = { + user_id: p.user_id, + bio: p.bio || "", + timezone: p.timezone || "", + clockedIn: p.clocked_in, + }; + } + + const settings = sqliteDb.prepare("SELECT * FROM settings").all(); + for (const s of settings) { + try { + state.settings[s.setting_key] = JSON.parse(s.setting_value); + } catch { + state.settings[s.setting_key] = s.setting_value; + } + } + + const blacklisted = sqliteDb.prepare("SELECT * FROM blacklisted_users").all(); + for (const b of blacklisted) { + state.blacklisted_users[b.user_id] = { + user_id: b.user_id, + reason: b.reason || "", + added_by: b.added_by || "", + created_at: b.created_at, + }; + } + + const suggestions = sqliteDb.prepare("SELECT * FROM suggestions").all(); + for (const s of suggestions) { + let upvotes = []; + let downvotes = []; + try { + upvotes = JSON.parse(s.upvotes || "[]"); + } catch { + upvotes = []; + } + try { + downvotes = JSON.parse(s.downvotes || "[]"); + } catch { + downvotes = []; + } + state.suggestions[s.id] = { + id: s.id, + author_id: s.author_id, + text: s.text, + status: s.status || "Created", + upvotes: Array.isArray(upvotes) ? upvotes : [], + downvotes: Array.isArray(downvotes) ? downvotes : [], + created_at: Number(s.created_at), + }; + } +} + +function initMySQL() { + const mysql = require("mysql2"); + const mysqlOpts = dbConfig.MySQL || {}; + + mysqlPool = mysql.createPool({ + host: mysqlOpts.Host || "127.0.0.1", + port: mysqlOpts.Port || 3306, + user: mysqlOpts.User || "supportbot", + password: mysqlOpts.Password || "", + database: mysqlOpts.Database || "supportbot", + connectionLimit: mysqlOpts.ConnectionLimit || 10, + waitForConnections: true, + }); + + const poolPromise = mysqlPool.promise(); + + (async () => { + try { + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS tickets ( + ticket_id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(255), + subject TEXT, + description TEXT, + department VARCHAR(255), + priority VARCHAR(255), + status VARCHAR(255), + ai_mode_enabled INT DEFAULT 0, + ai_channel_id VARCHAR(255), + ai_type VARCHAR(255), + ai_enabled_by VARCHAR(255), + ai_enabled_at BIGINT, + question_answers TEXT, + ticket_name VARCHAR(255), + ticket_number VARCHAR(255), + voice_channel_id VARCHAR(255), + created_at BIGINT, + updated_at BIGINT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS ticket_panels ( + id BIGINT PRIMARY KEY, + message_id VARCHAR(255), + channel_id VARCHAR(255), + created_at BIGINT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS ticket_users ( + ticket_id VARCHAR(255), + user_id VARCHAR(255), + PRIMARY KEY (ticket_id, user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS profiles ( + user_id VARCHAR(255) PRIMARY KEY, + bio TEXT, + timezone VARCHAR(255), + clocked_in INT DEFAULT 0 + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS settings ( + setting_key VARCHAR(255) PRIMARY KEY, + setting_value LONGTEXT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS blacklisted_users ( + user_id VARCHAR(255) PRIMARY KEY, + reason TEXT, + added_by VARCHAR(255), + created_at BIGINT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + await poolPromise.query(` + CREATE TABLE IF NOT EXISTS suggestions ( + id VARCHAR(255) PRIMARY KEY, + author_id VARCHAR(255), + text TEXT, + status VARCHAR(255) DEFAULT 'Created', + upvotes LONGTEXT, + downvotes LONGTEXT, + created_at BIGINT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + + // Load initial state from MySQL + const [tickets] = await poolPromise.query("SELECT * FROM tickets"); + for (const r of tickets) { + state.tickets[r.ticket_id] = { + ticket_id: r.ticket_id, + user_id: r.user_id, + subject: r.subject || "", + description: r.description || "", + department: r.department || "general", + priority: r.priority || "medium", + status: r.status || "open", + claimedBy: r.claimed_by || null, + claimed_by: r.claimed_by || null, + claimedAt: r.claimed_at ? Number(r.claimed_at) : null, + claimed_at: r.claimed_at ? Number(r.claimed_at) : null, + aiModeEnabled: r.ai_mode_enabled, + aiChannelId: r.ai_channel_id, + aiType: r.ai_type, + aiEnabledBy: r.ai_enabled_by, + aiEnabledAt: r.ai_enabled_at, + questionAnswers: r.question_answers || "[]", + ticketName: r.ticket_name, + ticketNumber: r.ticket_number, + voiceChannelId: r.voice_channel_id, + created_at: Number(r.created_at), + updated_at: Number(r.updated_at), + }; + } + + const [panels] = await poolPromise.query("SELECT * FROM ticket_panels ORDER BY id ASC"); + state.ticket_panel = panels.map((p) => ({ + id: Number(p.id), + message_id: p.message_id, + channel_id: p.channel_id, + created_at: Number(p.created_at), + })); + + const [ticketUsers] = await poolPromise.query("SELECT * FROM ticket_users"); + for (const tu of ticketUsers) { + state.ticket_users[tu.ticket_id] = state.ticket_users[tu.ticket_id] || []; + if (!state.ticket_users[tu.ticket_id].includes(tu.user_id)) { + state.ticket_users[tu.ticket_id].push(tu.user_id); + } + } + + const [profiles] = await poolPromise.query("SELECT * FROM profiles"); + for (const p of profiles) { + state.profiles[p.user_id] = { + user_id: p.user_id, + bio: p.bio || "", + timezone: p.timezone || "", + clockedIn: p.clocked_in, + }; + } + + const [settings] = await poolPromise.query("SELECT * FROM settings"); + for (const s of settings) { + try { + state.settings[s.setting_key] = JSON.parse(s.setting_value); + } catch { + state.settings[s.setting_key] = s.setting_value; + } + } + + const [blacklisted] = await poolPromise.query("SELECT * FROM blacklisted_users"); + for (const b of blacklisted) { + state.blacklisted_users[b.user_id] = { + user_id: b.user_id, + reason: b.reason || "", + added_by: b.added_by || "", + created_at: Number(b.created_at), + }; + } + + const [suggestions] = await poolPromise.query("SELECT * FROM suggestions"); + for (const s of suggestions) { + let upvotes = []; + let downvotes = []; + try { + upvotes = JSON.parse(s.upvotes || "[]"); + } catch { + upvotes = []; + } + try { + downvotes = JSON.parse(s.downvotes || "[]"); + } catch { + downvotes = []; + } + state.suggestions[s.id] = { + id: s.id, + author_id: s.author_id, + text: s.text, + status: s.status || "Created", + upvotes: Array.isArray(upvotes) ? upvotes : [], + downvotes: Array.isArray(downvotes) ? downvotes : [], + created_at: Number(s.created_at), + }; + } + + mysqlReady = true; + const clr = { reset: "\x1b[0m", gray: "\x1b[90m", green: "\x1b[32m", white: "\x1b[37m" }; + console.log(`${clr.green}✓${clr.reset} ${clr.white}Database Connected${clr.reset} ${clr.gray}to MySQL (${mysqlOpts.Host}:${mysqlOpts.Port}/${mysqlOpts.Database})${clr.reset}`); + + while (mysqlWriteQueue.length > 0) { + const fn = mysqlWriteQueue.shift(); + try { fn(); } catch (e) {} + } + } catch (err) { + console.error("[Database] Error initializing MySQL tables or state:", err.message); + console.warn("[Database] Falling back to SQLite database."); + mysqlReady = false; + if (mysqlPool) { + try { mysqlPool.end(); } catch (e) {} + mysqlPool = null; + } + initSQLite(); + } + })(); +} + +// ---------------------------------------------------- +// Legacy JSON Migration Utility +// ---------------------------------------------------- +function migrateLegacyJSON() { + if (!fs.existsSync(LEGACY_JSON_PATH)) return; + + try { + const raw = fs.readFileSync(LEGACY_JSON_PATH, "utf8"); + const jsonState = JSON.parse(raw); + console.log("[Database] Legacy supportbot.json detected. Migrating records to SQL..."); + + if (jsonState.tickets) { + for (const tId of Object.keys(jsonState.tickets)) { + const t = jsonState.tickets[tId]; + if (t && !state.tickets[tId]) { + const ticketObj = { + id: t.ticket_id || tId, + user: t.user_id, + reason: t.subject, + description: t.description, + department: t.department, + priority: t.priority, + aiModeEnabled: t.aiModeEnabled, + aiChannelId: t.aiChannelId, + aiType: t.aiType, + aiEnabledBy: t.aiEnabledBy, + aiEnabledAt: t.aiEnabledAt, + questionAnswers: typeof t.questionAnswers === "string" ? JSON.parse(t.questionAnswers) : t.questionAnswers || [], + name: t.ticketName, + number: t.ticketNumber, + }; + exportsModule.addTicket(ticketObj); + } + } + } + + if (jsonState.ticket_panel && Array.isArray(jsonState.ticket_panel)) { + for (const p of jsonState.ticket_panel) { + exportsModule.saveTicketPanel(p.message_id, p.channel_id); + } + } + + if (jsonState.profiles) { + for (const uId of Object.keys(jsonState.profiles)) { + const prof = jsonState.profiles[uId]; + if (prof) { + state.profiles[uId] = prof; + persistProfile(uId); + } + } + } + + if (jsonState.settings) { + exportsModule.saveSettings(jsonState.settings); + } + + // Archive legacy json file + const backupPath = LEGACY_JSON_PATH + ".bak"; + fs.renameSync(LEGACY_JSON_PATH, backupPath); + console.log(`[Database] Legacy supportbot.json successfully migrated and archived to ${backupPath}`); + } catch (e) { + console.error("[Database] Failed to migrate legacy supportbot.json:", e); + } + + // Migrate legacy BlacklistedUsers.json if present + const legacyBlacklistPath = path.join(__dirname, "../Data/BlacklistedUsers.json"); + if (fs.existsSync(legacyBlacklistPath)) { + try { + const raw = fs.readFileSync(legacyBlacklistPath, "utf8"); + const data = JSON.parse(raw); + if (data && Array.isArray(data.blacklistedUsers)) { + for (const uId of data.blacklistedUsers) { + exportsModule.addBlacklistedUser(uId, "Migrated from BlacklistedUsers.json", "System"); + } + } + fs.renameSync(legacyBlacklistPath, legacyBlacklistPath + ".bak"); + console.log(`[Database] Legacy BlacklistedUsers.json successfully migrated and archived.`); + } catch (e) { + console.error("[Database] Failed to migrate BlacklistedUsers.json:", e); + } + } +} + +// ---------------------------------------------------- +// SQL Persistence Helpers +// ---------------------------------------------------- +function persistTicket(ticketId) { + const t = state.tickets[ticketId]; + if (!t) return; + + if (isMySQL && mysqlReady && mysqlPool) { + const sql = ` + INSERT INTO tickets ( + ticket_id, user_id, subject, description, department, priority, status, + ai_mode_enabled, ai_channel_id, ai_type, ai_enabled_by, ai_enabled_at, + question_answers, ticket_name, ticket_number, voice_channel_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + user_id=VALUES(user_id), subject=VALUES(subject), description=VALUES(description), + department=VALUES(department), priority=VALUES(priority), status=VALUES(status), + ai_mode_enabled=VALUES(ai_mode_enabled), ai_channel_id=VALUES(ai_channel_id), + ai_type=VALUES(ai_type), ai_enabled_by=VALUES(ai_enabled_by), ai_enabled_at=VALUES(ai_enabled_at), + question_answers=VALUES(question_answers), ticket_name=VALUES(ticket_name), + ticket_number=VALUES(ticket_number), voice_channel_id=VALUES(voice_channel_id), updated_at=VALUES(updated_at); + `; + const params = [ + t.ticket_id, + t.user_id || "", + t.subject || "", + t.description || "", + t.department || "general", + t.priority || "medium", + t.status || "open", + t.aiModeEnabled ? 1 : 0, + t.aiChannelId || null, + t.aiType || null, + t.aiEnabledBy || null, + t.aiEnabledAt || null, + typeof t.questionAnswers === "string" ? t.questionAnswers : JSON.stringify(t.questionAnswers || []), + t.ticketName || null, + t.ticketNumber || null, + t.voiceChannelId || null, + t.created_at || Date.now(), + t.updated_at || Date.now(), + ]; + mysqlPool.query(sql, params, (err) => { + if (err) console.error("[Database] MySQL persistTicket error:", err.message); + }); + } else if (sqliteDb) { + const stmt = sqliteDb.prepare(` + INSERT INTO tickets ( + ticket_id, user_id, subject, description, department, priority, status, + ai_mode_enabled, ai_channel_id, ai_type, ai_enabled_by, ai_enabled_at, + question_answers, ticket_name, ticket_number, voice_channel_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ticket_id) DO UPDATE SET + user_id=excluded.user_id, subject=excluded.subject, description=excluded.description, + department=excluded.department, priority=excluded.priority, status=excluded.status, + ai_mode_enabled=excluded.ai_mode_enabled, ai_channel_id=excluded.ai_channel_id, + ai_type=excluded.ai_type, ai_enabled_by=excluded.ai_enabled_by, ai_enabled_at=excluded.ai_enabled_at, + question_answers=excluded.question_answers, ticket_name=excluded.ticket_name, + ticket_number=excluded.ticket_number, voice_channel_id=excluded.voice_channel_id, updated_at=excluded.updated_at; + `); + stmt.run( + t.ticket_id, + t.user_id || "", + t.subject || "", + t.description || "", + t.department || "general", + t.priority || "medium", + t.status || "open", + t.aiModeEnabled ? 1 : 0, + t.aiChannelId || null, + t.aiType || null, + t.aiEnabledBy || null, + t.aiEnabledAt || null, + typeof t.questionAnswers === "string" ? t.questionAnswers : JSON.stringify(t.questionAnswers || []), + t.ticketName || null, + t.ticketNumber || null, + t.voiceChannelId || null, + t.created_at || Date.now(), + t.updated_at || Date.now() + ); + } +} + +function persistPanel(panelRow) { + if (isMySQL && mysqlReady && mysqlPool) { + mysqlPool.query( + "INSERT INTO ticket_panels (id, message_id, channel_id, created_at) VALUES (?, ?, ?, ?)", + [panelRow.id, panelRow.message_id, panelRow.channel_id, panelRow.created_at], + (err) => { + if (err) console.error("[Database] MySQL persistPanel error:", err.message); + } + ); + } else if (sqliteDb) { + const stmt = sqliteDb.prepare("INSERT INTO ticket_panels (id, message_id, channel_id, created_at) VALUES (?, ?, ?, ?)"); + stmt.run(panelRow.id, panelRow.message_id, panelRow.channel_id, panelRow.created_at); + } +} + +function persistTicketUser(ticketId, userId, action = "add") { + if (action === "add") { + if (isMySQL && mysqlReady && mysqlPool) { + mysqlPool.query("INSERT IGNORE INTO ticket_users (ticket_id, user_id) VALUES (?, ?)", [ticketId, userId], (err) => { + if (err) console.error("[Database] MySQL persistTicketUser error:", err.message); + }); + } else if (sqliteDb) { + sqliteDb.prepare("INSERT OR IGNORE INTO ticket_users (ticket_id, user_id) VALUES (?, ?)").run(ticketId, userId); + } + } else { + if (isMySQL && mysqlReady && mysqlPool) { + mysqlPool.query("DELETE FROM ticket_users WHERE ticket_id = ? AND user_id = ?", [ticketId, userId], (err) => { + if (err) console.error("[Database] MySQL deleteTicketUser error:", err.message); + }); + } else if (sqliteDb) { + sqliteDb.prepare("DELETE FROM ticket_users WHERE ticket_id = ? AND user_id = ?").run(ticketId, userId); + } + } +} + +function persistProfile(userId) { + const p = state.profiles[userId]; + if (!p) return; + + if (isMySQL && mysqlReady && mysqlPool) { + const sql = ` + INSERT INTO profiles (user_id, bio, timezone, clocked_in) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE bio=VALUES(bio), timezone=VALUES(timezone), clocked_in=VALUES(clocked_in); + `; + mysqlPool.query(sql, [p.user_id, p.bio || "", p.timezone || "", p.clockedIn ? 1 : 0], (err) => { + if (err) console.error("[Database] MySQL persistProfile error:", err.message); + }); + } else if (sqliteDb) { + const stmt = sqliteDb.prepare(` + INSERT INTO profiles (user_id, bio, timezone, clocked_in) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET bio=excluded.bio, timezone=excluded.timezone, clocked_in=excluded.clocked_in; + `); + stmt.run(p.user_id, p.bio || "", p.timezone || "", p.clockedIn ? 1 : 0); + } +} + +function persistSettings() { + const settingsObj = state.settings || {}; + for (const k of Object.keys(settingsObj)) { + const val = JSON.stringify(settingsObj[k]); + if (isMySQL && mysqlPool) { + mysqlPool.query( + "INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)", + [k, val], + (err) => { + if (err) console.error("[Database] MySQL persistSettings error:", err.message); + } + ); + } else if (sqliteDb) { + sqliteDb.prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value").run(k, val); + } + } +} + +function persistBlacklistedUser(userId, action = "add") { + if (isMySQL && mysqlReady && !mysqlPool) { + mysqlWriteQueue.push(() => persistBlacklistedUser(userId, action)); + return; + } + const b = state.blacklisted_users[userId]; + if (action === "add" && b) { + if (isMySQL && mysqlReady && mysqlPool) { + const sql = ` + INSERT INTO blacklisted_users (user_id, reason, added_by, created_at) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE reason=VALUES(reason), added_by=VALUES(added_by), created_at=VALUES(created_at); + `; + mysqlPool.query(sql, [b.user_id, b.reason || "", b.added_by || "", b.created_at || Date.now()], (err) => { + if (err) console.error("[Database] MySQL persistBlacklistedUser error:", err.message); + }); + } else if (sqliteDb) { + const stmt = sqliteDb.prepare(` + INSERT INTO blacklisted_users (user_id, reason, added_by, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET reason=excluded.reason, added_by=excluded.added_by, created_at=excluded.created_at; + `); + stmt.run(b.user_id, b.reason || "", b.added_by || "", b.created_at || Date.now()); + } + } else { + if (isMySQL && mysqlReady && mysqlPool) { + mysqlPool.query("DELETE FROM blacklisted_users WHERE user_id = ?", [userId], (err) => { + if (err) console.error("[Database] MySQL removeBlacklistedUser error:", err.message); + }); + } else if (sqliteDb) { + sqliteDb.prepare("DELETE FROM blacklisted_users WHERE user_id = ?").run(userId); + } + } +} + +function persistSuggestion(id) { + const s = state.suggestions[id]; + if (!s) return; + + const upvotesStr = JSON.stringify(s.upvotes || []); + const downvotesStr = JSON.stringify(s.downvotes || []); + + if (isMySQL && mysqlReady && mysqlPool) { + const sql = ` + INSERT INTO suggestions (id, author_id, text, status, upvotes, downvotes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE text=VALUES(text), status=VALUES(status), upvotes=VALUES(upvotes), downvotes=VALUES(downvotes); + `; + mysqlPool.query(sql, [s.id, s.author_id || "", s.text || "", s.status || "Created", upvotesStr, downvotesStr, s.created_at || Date.now()], (err) => { + if (err) console.error("[Database] MySQL persistSuggestion error:", err.message); + }); + } else if (sqliteDb) { + const stmt = sqliteDb.prepare(` + INSERT INTO suggestions (id, author_id, text, status, upvotes, downvotes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET text=excluded.text, status=excluded.status, upvotes=excluded.upvotes, downvotes=excluded.downvotes; + `); + stmt.run(s.id, s.author_id || "", s.text || "", s.status || "Created", upvotesStr, downvotesStr, s.created_at || Date.now()); + } +} function ensureProfile(userId) { - const existing = db - .prepare( - ` - SELECT user_id FROM profiles WHERE user_id = ? - `, - ) - .get(userId); - - if (!existing) { - db.prepare( - ` - INSERT INTO profiles (user_id, bio, timezone, clockedIn) - VALUES (?, '', '', 0) - `, - ).run(userId); + if (!state.profiles[userId]) { + state.profiles[userId] = { user_id: userId, bio: "", timezone: "", clockedIn: 0 }; + persistProfile(userId); } } function normaliseTicketRow(row) { if (!row) return null; - let questionAnswers = []; - if (row.questionAnswers) { try { - questionAnswers = JSON.parse(row.questionAnswers); + questionAnswers = typeof row.questionAnswers === "string" ? JSON.parse(row.questionAnswers) : row.questionAnswers; } catch { questionAnswers = []; } } + const claimedBy = row.claimedBy || row.claimed_by || null; + const claimedAt = row.claimedAt || row.claimed_at || null; + return { ...row, + claimedBy, + claimed_by: claimedBy, + claimedAt, + claimed_at: claimedAt, aiModeEnabled: Boolean(row.aiModeEnabled), - clockedIn: - row.clockedIn !== undefined ? Boolean(row.clockedIn) : row.clockedIn, + clockedIn: row.clockedIn !== undefined ? Boolean(row.clockedIn) : row.clockedIn, questionAnswers, }; } -module.exports = { +const exportsModule = { addTicket(ticket) { const now = Date.now(); + const id = ticket.id; + state.tickets[id] = state.tickets[id] || {}; + const existing = state.tickets[id]; - db.prepare( - ` - INSERT INTO tickets ( - ticket_id, - user_id, - subject, - description, - created_at, - updated_at, - department, - priority, - aiModeEnabled, - aiChannelId, - aiType, - aiEnabledBy, - aiEnabledAt, - questionAnswers, - ticketName, - ticketNumber - ) - VALUES ( - @ticket_id, - @user_id, - @subject, - @description, - @created_at, - @updated_at, - @department, - @priority, - @aiModeEnabled, - @aiChannelId, - @aiType, - @aiEnabledBy, - @aiEnabledAt, - @questionAnswers, - @ticketName, - @ticketNumber - ) - ON CONFLICT(ticket_id) DO NOTHING - `, - ).run({ - ticket_id: ticket.id, + state.tickets[id] = { + ticket_id: id, user_id: ticket.user, subject: ticket.reason || "", description: ticket.description || "", - created_at: now, + created_at: existing.created_at || now, updated_at: now, department: ticket.department || "general", priority: ticket.priority || "medium", @@ -193,282 +803,322 @@ module.exports = { questionAnswers: JSON.stringify(ticket.questionAnswers || []), ticketName: ticket.name || null, ticketNumber: ticket.number || null, - }); + status: existing.status || "open", + }; + + persistTicket(id); + + if (global.apiServer) { + global.apiServer.broadcast("ticket_created", { + ticket_id: id, + user_id: ticket.user, + subject: ticket.reason || "", + description: ticket.description || "", + department: ticket.department || "general", + priority: ticket.priority || "medium", + created_at: now, + }); + } }, updateTicketQuestionAnswers(ticketId, questionAnswers = []) { - db.prepare( - ` - UPDATE tickets - SET questionAnswers=@questionAnswers, updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - questionAnswers: JSON.stringify(questionAnswers), - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.questionAnswers = JSON.stringify(questionAnswers); + t.updated_at = Date.now(); + persistTicket(ticketId); }, updateTicketVoice(ticketId, voiceChannelId) { - db.prepare( - ` - UPDATE tickets - SET voiceChannelId=@voiceChannelId, updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - voiceChannelId, - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.voiceChannelId = voiceChannelId; + t.updated_at = Date.now(); + persistTicket(ticketId); }, updateTicketStatus(ticketId, status) { - db.prepare( - ` - UPDATE tickets - SET status=@status, updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - status, - updatedAt: Date.now(), - ticketId, - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.status = status; + t.updated_at = Date.now(); + persistTicket(ticketId); + + if (global.apiServer && status === "closed") { + global.apiServer.broadcast("ticket_closed", { ticket_id: ticketId }); + } }, updateTicketDepartment(ticketId, department) { - db.prepare( - ` - UPDATE tickets - SET department=@department, updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - department, - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.department = department; + t.updated_at = Date.now(); + persistTicket(ticketId); }, updateTicketPriority(ticketId, priority) { - db.prepare( - ` - UPDATE tickets - SET priority=@priority, updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - priority, - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.priority = priority; + t.updated_at = Date.now(); + persistTicket(ticketId); }, setTicketAIState(ticketId, aiState = {}) { - db.prepare( - ` - UPDATE tickets - SET - aiModeEnabled=@aiModeEnabled, - aiChannelId=@aiChannelId, - aiType=@aiType, - aiEnabledBy=@aiEnabledBy, - aiEnabledAt=@aiEnabledAt, - updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - aiModeEnabled: aiState.enabled ? 1 : 0, - aiChannelId: aiState.aiChannelId || null, - aiType: aiState.aiType || null, - aiEnabledBy: aiState.enabledBy || null, - aiEnabledAt: aiState.enabledAt - ? new Date(aiState.enabledAt).getTime() - : aiState.enabled - ? Date.now() - : null, - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.aiModeEnabled = aiState.enabled ? 1 : 0; + t.aiChannelId = aiState.aiChannelId || null; + t.aiType = aiState.aiType || null; + t.aiEnabledBy = aiState.enabledBy || null; + t.aiEnabledAt = aiState.enabledAt ? new Date(aiState.enabledAt).getTime() : aiState.enabled ? Date.now() : null; + t.updated_at = Date.now(); + persistTicket(ticketId); }, - enableTicketAI( - ticketId, - aiChannelId = null, - aiType = null, - enabledBy = null, - ) { - db.prepare( - ` - UPDATE tickets - SET - aiModeEnabled=1, - aiChannelId=@aiChannelId, - aiType=@aiType, - aiEnabledBy=@aiEnabledBy, - aiEnabledAt=@aiEnabledAt, - updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - aiChannelId, - aiType, - aiEnabledBy, - aiEnabledAt: Date.now(), - updatedAt: Date.now(), - }); + enableTicketAI(ticketId, aiChannelId = null, aiType = null, enabledBy = null) { + const t = state.tickets[ticketId]; + if (!t) return; + t.aiModeEnabled = 1; + t.aiChannelId = aiChannelId; + t.aiType = aiType; + t.aiEnabledBy = enabledBy; + t.aiEnabledAt = Date.now(); + t.updated_at = Date.now(); + persistTicket(ticketId); }, disableTicketAI(ticketId) { - db.prepare( - ` - UPDATE tickets - SET - aiModeEnabled=0, - updated_at=@updatedAt - WHERE ticket_id=@ticketId - `, - ).run({ - ticketId, - updatedAt: Date.now(), - }); + const t = state.tickets[ticketId]; + if (!t) return; + t.aiModeEnabled = 0; + t.updated_at = Date.now(); + persistTicket(ticketId); }, getTicket(ticketId) { - const row = db - .prepare( - ` - SELECT * FROM tickets WHERE ticket_id=@ticketId - `, - ) - .get({ ticketId }); - - return normaliseTicketRow(row); + return normaliseTicketRow(state.tickets[ticketId]); }, getTicketByAIChannel(aiChannelId) { - const row = db - .prepare( - ` - SELECT * FROM tickets WHERE aiChannelId=@aiChannelId LIMIT 1 - `, - ) - .get({ aiChannelId }); - - return normaliseTicketRow(row); + const keys = Object.keys(state.tickets); + for (const k of keys) { + const t = state.tickets[k]; + if (t && t.aiChannelId === aiChannelId) return normaliseTicketRow(t); + } + return null; }, getAllTickets() { - return db.prepare(`SELECT * FROM tickets`).all().map(normaliseTicketRow); + return Object.values(state.tickets).map(normaliseTicketRow); }, saveTicketPanel(messageId, channelId) { - db.prepare( - ` - INSERT INTO ticket_panel (message_id, channel_id, created_at) - VALUES (@messageId, @channelId, @createdAt) - `, - ).run({ messageId, channelId, createdAt: Date.now() }); + const row = { id: Date.now(), message_id: messageId, channel_id: channelId, created_at: Date.now() }; + state.ticket_panel.push(row); + persistPanel(row); }, getTicketPanel() { - return db - .prepare( - ` - SELECT * FROM ticket_panel ORDER BY id DESC LIMIT 1 - `, - ) - .get(); + const arr = state.ticket_panel; + if (!arr || arr.length === 0) return null; + return arr[arr.length - 1]; }, addUserToTicket(ticketId, userId) { - db.prepare( - ` - INSERT INTO ticket_users (ticket_id, user_id) - VALUES (?, ?) - ON CONFLICT(ticket_id, user_id) DO NOTHING - `, - ).run(ticketId, userId); + state.ticket_users[ticketId] = state.ticket_users[ticketId] || []; + if (!state.ticket_users[ticketId].includes(userId)) { + state.ticket_users[ticketId].push(userId); + persistTicketUser(ticketId, userId, "add"); + } }, removeUserFromTicket(ticketId, userId) { - db.prepare( - ` - DELETE FROM ticket_users WHERE ticket_id=? AND user_id=? - `, - ).run(ticketId, userId); + if (state.ticket_users[ticketId]) { + state.ticket_users[ticketId] = state.ticket_users[ticketId].filter((u) => u !== userId); + persistTicketUser(ticketId, userId, "remove"); + } }, getTicketUsers(ticketId) { - return db - .prepare( - ` - SELECT user_id FROM ticket_users WHERE ticket_id=? - `, - ) - .all(ticketId) - .map((r) => r.user_id); + return (state.ticket_users[ticketId] || []).slice(); }, getSettings() { - const row = db.prepare(`SELECT data FROM settings WHERE id=1`).get(); - if (!row) return {}; - try { - return JSON.parse(row.data); - } catch { - return {}; - } + return state.settings || {}; }, getProfile(userId) { ensureProfile(userId); - - const row = db - .prepare( - ` - SELECT * FROM profiles WHERE user_id = ? - `, - ) - .get(userId); - - if (!row) { - return { - user_id: userId, - bio: "", - timezone: "", - clockedIn: false, - }; - } - - return { - ...row, - clockedIn: Boolean(row.clockedIn), - }; + const row = state.profiles[userId]; + if (!row) return { user_id: userId, bio: "", timezone: "", clockedIn: false }; + return { ...row, clockedIn: Boolean(row.clockedIn) }; }, setClockedIn(userId, clockedIn) { ensureProfile(userId); - - db.prepare( - ` - UPDATE profiles - SET clockedIn = ? - WHERE user_id = ? - `, - ).run(clockedIn ? 1 : 0, userId); + state.profiles[userId].clockedIn = clockedIn ? 1 : 0; + persistProfile(userId); }, saveSettings(obj) { - const data = JSON.stringify(obj); - db.prepare( - ` - INSERT INTO settings (id, data) - VALUES (1, @data) - ON CONFLICT(id) DO UPDATE SET data = excluded.data - `, - ).run({ data }); + state.settings = obj || {}; + persistSettings(); + }, + + addBlacklistedUser(userId, reason = "", addedBy = "") { + state.blacklisted_users[userId] = { + user_id: userId, + reason, + added_by: addedBy, + created_at: Date.now(), + }; + persistBlacklistedUser(userId, "add"); + }, + + removeBlacklistedUser(userId) { + if (state.blacklisted_users[userId]) { + delete state.blacklisted_users[userId]; + persistBlacklistedUser(userId, "remove"); + } + }, + + isUserBlacklisted(userId) { + return Boolean(state.blacklisted_users[userId]); + }, + + getBlacklistedUsers() { + return Object.keys(state.blacklisted_users); + }, + + getBlacklistedUserDetails() { + return Object.values(state.blacklisted_users); + }, + + claimTicket(ticketId, userId) { + const t = state.tickets[ticketId]; + if (!t) return; + t.claimedBy = userId; + t.claimed_by = userId; + t.claimedAt = Date.now(); + t.claimed_at = Date.now(); + t.updated_at = Date.now(); + persistTicket(ticketId); + }, + + addSuggestion(id, authorId, text) { + state.suggestions[id] = { + id, + author_id: authorId, + text, + status: "Created", + upvotes: [], + downvotes: [], + created_at: Date.now(), + }; + persistSuggestion(id); + }, + + add(id, authorId, text) { + this.addSuggestion(id, authorId, text); + }, + + getSuggestion(id) { + const s = state.suggestions[id]; + if (!s) return null; + return { + ...s, + upvotes: Array.isArray(s.upvotes) ? s.upvotes : [], + downvotes: Array.isArray(s.downvotes) ? s.downvotes : [], + }; + }, + + setSuggestionStatus(id, status) { + const s = state.suggestions[id]; + if (!s) return; + s.status = status; + persistSuggestion(id); + }, + + setStatus(id, status) { + this.setSuggestionStatus(id, status); + }, + + voteSuggestion(id, userId, voteType) { + let s = state.suggestions[id]; + if (!s) { + s = { + id, + author_id: "", + text: "", + status: "Created", + upvotes: [], + downvotes: [], + created_at: Date.now(), + }; + state.suggestions[id] = s; + } + + if (!Array.isArray(s.upvotes)) s.upvotes = []; + if (!Array.isArray(s.downvotes)) s.downvotes = []; + + const hasUpvoted = s.upvotes.includes(userId); + const hasDownvoted = s.downvotes.includes(userId); + + if (voteType === "upvote") { + if (hasUpvoted) { + s.upvotes = s.upvotes.filter((uid) => uid !== userId); + } else { + s.upvotes.push(userId); + if (hasDownvoted) { + s.downvotes = s.downvotes.filter((uid) => uid !== userId); + } + } + } else if (voteType === "downvote") { + if (hasDownvoted) { + s.downvotes = s.downvotes.filter((uid) => uid !== userId); + } else { + s.downvotes.push(userId); + if (hasUpvoted) { + s.upvotes = s.upvotes.filter((uid) => uid !== userId); + } + } + } else if (voteType === "removevote") { + s.upvotes = s.upvotes.filter((uid) => uid !== userId); + s.downvotes = s.downvotes.filter((uid) => uid !== userId); + } + + persistSuggestion(id); + + return { + suggestion: s, + upvoteCount: s.upvotes.length, + downvoteCount: s.downvotes.length, + }; + }, + + getDatabaseInfo() { + if (isMySQL) { + const mysqlOpts = dbConfig.MySQL || {}; + return { + driver: "MySQL", + host: mysqlOpts.Host || "127.0.0.1", + port: mysqlOpts.Port || 3306, + database: mysqlOpts.Database || "supportbot", + type: "mysql" + }; + } + const sqliteOpts = dbConfig.SQLite || {}; + return { + driver: "SQLite", + file: sqliteOpts.File || "./Data/supportbot.db", + type: "sqlite" + }; }, }; + +// Initialize database on require +initDatabase(); + +module.exports = exportsModule; diff --git a/Structures/DiscordOAuth.js b/Structures/DiscordOAuth.js new file mode 100644 index 0000000..36e1175 --- /dev/null +++ b/Structures/DiscordOAuth.js @@ -0,0 +1,233 @@ +const crypto = require("crypto"); +const axios = require("axios"); + +const DISCORD_API = "https://discord.com/api"; +const OAUTH_STATE_COOKIE = "sb_oauth_state"; +const OAUTH_REDIRECT_COOKIE = "sb_oauth_redirect"; +const OAUTH_STATE_MAX_AGE_MS = 10 * 60 * 1000; +const CALLBACK_PATH = "/api/auth/discord/callback"; + +function discordAvatarUrl(user) { + if (user.avatar) { + const ext = user.avatar.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`; + } + const index = Number((BigInt(user.id) >> 22n) % 6n); + return `https://cdn.discordapp.com/embed/avatars/${index}.png`; +} + +function getTrustProxySetting(apiConfig) { + if (apiConfig?.TrustProxy === false) return false; + if (typeof apiConfig?.TrustProxy === "number") return apiConfig.TrustProxy; + if (apiConfig?.TrustProxy === true) return 1; + return false; +} + +function getRequestProto(req, apiConfig) { + if (apiConfig?.TrustProxy !== false) { + const forwarded = req.get("x-forwarded-proto"); + if (forwarded) { + return forwarded.split(",")[0].trim().toLowerCase(); + } + } + return (req.protocol || "http").toLowerCase(); +} + +function getRequestHost(req) { + const forwarded = req.get("x-forwarded-host"); + if (forwarded) { + return forwarded.split(",")[0].trim(); + } + return req.get("host") || ""; +} + +function getRequestOrigin(req, apiConfig) { + const proto = getRequestProto(req, apiConfig); + const host = getRequestHost(req); + if (!host) return null; + return `${proto}://${host}`; +} + +function normalizeRedirectUri(uri) { + try { + const u = new URL(uri); + u.hash = ""; + u.search = ""; + return u.toString(); + } catch { + return null; + } +} + +function redirectUriMatchesHostPath(a, b) { + try { + const ua = new URL(a); + const ub = new URL(b); + return ua.host === ub.host && ua.pathname === ub.pathname; + } catch { + return false; + } +} + +function collectConfiguredRedirectUris(oauth) { + const list = []; + if (oauth?.RedirectUri) { + const normalized = normalizeRedirectUri(oauth.RedirectUri); + if (normalized) list.push(normalized); + } + if (Array.isArray(oauth?.RedirectUris)) { + for (const entry of oauth.RedirectUris) { + const normalized = normalizeRedirectUri(entry); + if (normalized && !list.includes(normalized)) { + list.push(normalized); + } + } + } + return list; +} + +/** + * Pick the OAuth redirect_uri for this request. When UseRequestOrigin is enabled + * (default), uses the same host/path as RedirectUri but the protocol the user + * actually used (http vs https). Register every variant in the Discord portal. + */ +function resolveOAuthRedirectUri(req, oauth, apiConfig) { + const configured = collectConfiguredRedirectUris(oauth); + const fallback = configured[0] || oauth?.RedirectUri || ""; + + const useRequestOrigin = oauth?.UseRequestOrigin !== false; + if (!useRequestOrigin || !req) { + return fallback; + } + + const origin = getRequestOrigin(req, apiConfig); + if (!origin) return fallback; + + const dynamic = `${origin}${CALLBACK_PATH}`; + const normalizedDynamic = normalizeRedirectUri(dynamic); + if (!normalizedDynamic) return fallback; + + if (configured.includes(normalizedDynamic)) { + return normalizedDynamic; + } + + const matchesConfigured = configured.some((uri) => + redirectUriMatchesHostPath(uri, normalizedDynamic), + ); + if (matchesConfigured) { + return normalizedDynamic; + } + + return fallback; +} + +function isSecureRequest(req, oauth, apiConfig) { + if (oauth?.CookieSecure === true) return true; + if (oauth?.CookieSecure === false) return false; + if (req) { + if (typeof req.secure === "boolean") return req.secure; + return getRequestProto(req, apiConfig) === "https"; + } + return process.env.NODE_ENV === "production"; +} + +function buildAuthorizeUrl(oauth, state, redirectUri) { + const params = new URLSearchParams({ + client_id: oauth.ClientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "identify", + state, + prompt: "none", + }); + return `${DISCORD_API}/oauth2/authorize?${params.toString()}`; +} + +async function exchangeCode(oauth, code, redirectUri) { + const body = new URLSearchParams({ + client_id: oauth.ClientId, + client_secret: oauth.ClientSecret, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + }); + + const { data } = await axios.post(`${DISCORD_API}/oauth2/token`, body, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: 15000, + }); + + return data.access_token; +} + +async function fetchDiscordUser(accessToken) { + const { data } = await axios.get(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 15000, + }); + return data; +} + +function createOAuthState() { + return crypto.randomBytes(24).toString("hex"); +} + +function oauthStateCookieOptions(req, oauth, apiConfig) { + return { + httpOnly: true, + secure: isSecureRequest(req, oauth, apiConfig), + sameSite: "lax", + maxAge: OAUTH_STATE_MAX_AGE_MS, + path: "/", + }; +} + +function oauthRedirectCookieOptions(req, oauth, apiConfig) { + return oauthStateCookieOptions(req, oauth, apiConfig); +} + +function isOAuthConfigured(oauth) { + return Boolean( + oauth?.Enabled && + oauth.ClientId && + oauth.ClientSecret && + oauth.RedirectUri, + ); +} + +function isUserAllowed(userId, oauth) { + const allowed = oauth?.AllowedUserIds; + if (!Array.isArray(allowed) || allowed.length === 0) { + return false; + } + return allowed.map(String).includes(String(userId)); +} + +function formatPublicUser(user) { + return { + id: user.id, + username: user.username, + globalName: user.global_name || null, + avatar: discordAvatarUrl(user), + }; +} + +module.exports = { + OAUTH_STATE_COOKIE, + OAUTH_REDIRECT_COOKIE, + OAUTH_STATE_MAX_AGE_MS, + CALLBACK_PATH, + getTrustProxySetting, + resolveOAuthRedirectUri, + isSecureRequest, + buildAuthorizeUrl, + exchangeCode, + fetchDiscordUser, + createOAuthState, + oauthStateCookieOptions, + oauthRedirectCookieOptions, + isOAuthConfigured, + isUserAllowed, + formatPublicUser, + discordAvatarUrl, +}; diff --git a/Structures/GuildManager.js b/Structures/GuildManager.js new file mode 100644 index 0000000..3298ce3 --- /dev/null +++ b/Structures/GuildManager.js @@ -0,0 +1,277 @@ +const { ChannelType } = require("discord.js"); +const configStore = require("./ConfigStore.js"); +const { withTimeout } = require("./asyncTimeout.js"); + +const RESOURCES_CACHE_MS = 90 * 1000; +/** Bump when resource payload shape changes (e.g. added emojis). */ +const RESOURCES_CACHE_VERSION = 2; +let resourcesCache = { guildId: null, data: null, at: 0, version: 0 }; +let resourcesInflight = null; + +function getConfiguredGuildId() { + const id = configStore.supportbot?.General?.GuildId; + if (!id || id === "GUILD_ID" || String(id).trim() === "") return null; + return String(id).trim(); +} + +function setConfiguredGuildId(guildId) { + if (!configStore.supportbot.General) { + configStore.supportbot.General = {}; + } + configStore.supportbot.General.GuildId = guildId ? String(guildId) : ""; +} + +function getPrimaryGuild(client) { + if (!client) return null; + + const configuredId = getConfiguredGuildId(); + if (configuredId) { + const match = client.guilds.cache.get(configuredId); + if (match) return match; + } + + return client.guilds.cache.first() || null; +} + +function evaluateGuildHealth(client) { + const configuredId = getConfiguredGuildId(); + const guilds = [...client.guilds.cache.values()]; + + if (!configuredId) { + return { + status: "missing_config", + configuredGuildId: null, + message: + "No guild ID is set. Add your Discord server ID under Bot config → General.", + configuredGuild: null, + extraGuilds: guilds.map(formatGuildSummary), + }; + } + + const configuredGuild = client.guilds.cache.get(configuredId) || null; + const extraGuilds = guilds + .filter((g) => g.id !== configuredId) + .map(formatGuildSummary); + + if (!configuredGuild) { + return { + status: "not_in_guild", + configuredGuildId: configuredId, + message: `The bot is not in the configured server (${configuredId}). Invite it or update the guild ID.`, + configuredGuild: null, + extraGuilds: guilds.map(formatGuildSummary), + }; + } + + if (extraGuilds.length > 0) { + return { + status: "extra_guilds", + configuredGuildId: configuredId, + message: `The bot is in ${extraGuilds.length} extra server(s). Leave them so only your configured server remains.`, + configuredGuild: formatGuildSummary(configuredGuild), + extraGuilds, + }; + } + + return { + status: "ok", + configuredGuildId: configuredId, + message: null, + configuredGuild: formatGuildSummary(configuredGuild), + extraGuilds: [], + }; +} + +function formatGuildSummary(guild) { + return { + id: guild.id, + name: guild.name, + memberCount: guild.memberCount ?? null, + icon: guild.iconURL?.({ size: 128 }) ?? null, + }; +} + +function syncGuildHealth(client) { + if (!client?.user) { + client.guildHealth = { + status: "offline", + configuredGuildId: getConfiguredGuildId(), + message: "Bot is not connected to Discord.", + configuredGuild: null, + extraGuilds: [], + }; + return client.guildHealth; + } + + client.guildHealth = evaluateGuildHealth(client); + return client.guildHealth; +} + +function logGuildWarning(client, message) { + const name = client?.user?.username || "Bot"; + console.warn(`\x1b[33m[Guild]\x1b[0m ${name}: ${message}`); +} + +async function leaveGuild(client, guildId) { + const guild = client.guilds.cache.get(String(guildId)); + if (!guild) { + throw new Error("Bot is not in that server."); + } + + const configuredId = getConfiguredGuildId(); + if (configuredId && guild.id === configuredId) { + throw new Error("Cannot leave the configured server. Change the guild ID first."); + } + + await guild.leave(); + syncGuildHealth(client); + return syncGuildHealth(client); +} + +function buildResourcesFromGuild(guild) { + const roles = [...guild.roles.cache.values()] + .filter((r) => r.id !== guild.id) + .sort((a, b) => b.position - a.position) + .map((r) => ({ + id: r.id, + name: r.name, + color: r.hexColor || null, + })); + + const channels = [...guild.channels.cache.values()] + .filter( + (c) => + c.type === ChannelType.GuildText || + c.type === ChannelType.GuildVoice || + c.type === ChannelType.GuildAnnouncement || + c.type === ChannelType.GuildStageVoice || + c.type === ChannelType.GuildForum, + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + })); + + const categories = [...guild.channels.cache.values()] + .filter((c) => c.type === ChannelType.GuildCategory) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ + id: c.id, + name: c.name, + })); + + const emojis = [...guild.emojis.cache.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => { + const animated = Boolean(e.animated); + const ext = animated ? "gif" : "png"; + return { + id: e.id, + name: e.name, + animated, + url: + e.imageURL({ extension: ext, size: 64 }) || + `https://cdn.discordapp.com/emojis/${e.id}.${ext}?size=64`, + }; + }); + + return { + guildId: guild.id, + guildName: guild.name, + guildIcon: guild.iconURL({ extension: "png", size: 64 }) || null, + roles, + channels, + categories, + emojis, + }; +} + +async function fetchGuildResourcesFromDiscord(client, guildId) { + const id = guildId || getConfiguredGuildId(); + if (!id) return null; + + const guild = client.guilds.cache.get(id); + if (!guild) return null; + + try { + await withTimeout( + Promise.all([guild.channels.fetch(), guild.roles.fetch()]), + 12_000, + "Discord channel/role fetch timed out", + ); + } catch (err) { + console.warn("[Guild] Failed to fetch channels/roles:", err.message); + if (guild.roles.cache.size === 0 && guild.channels.cache.size === 0) { + return null; + } + } + + try { + await withTimeout(guild.emojis.fetch(), 12_000, "Discord emoji fetch timed out"); + } catch (err) { + console.warn("[Guild] Failed to fetch guild emojis:", err.message); + } + + return buildResourcesFromGuild(guild); +} + +async function fetchGuildResources(client, guildId) { + const id = guildId || getConfiguredGuildId(); + if (!id || !client?.user) return null; + + const now = Date.now(); + if ( + resourcesCache.data && + resourcesCache.version === RESOURCES_CACHE_VERSION && + resourcesCache.guildId === id && + now - resourcesCache.at < RESOURCES_CACHE_MS + ) { + return resourcesCache.data; + } + + if (resourcesInflight) { + try { + return await resourcesInflight; + } catch { + /* fall through to retry */ + } + } + + resourcesInflight = (async () => { + const data = await fetchGuildResourcesFromDiscord(client, id); + if (data) { + resourcesCache = { + guildId: id, + data, + at: Date.now(), + version: RESOURCES_CACHE_VERSION, + }; + } + return data; + })(); + + try { + return await resourcesInflight; + } finally { + resourcesInflight = null; + } +} + +function clearGuildResourcesCache() { + resourcesCache = { guildId: null, data: null, at: 0, version: 0 }; + resourcesInflight = null; +} + +module.exports = { + getConfiguredGuildId, + setConfiguredGuildId, + getPrimaryGuild, + evaluateGuildHealth, + syncGuildHealth, + logGuildWarning, + leaveGuild, + fetchGuildResources, + clearGuildResourcesCache, +}; diff --git a/Structures/LogReader.js b/Structures/LogReader.js new file mode 100644 index 0000000..b0f4dbb --- /dev/null +++ b/Structures/LogReader.js @@ -0,0 +1,181 @@ +const fs = require("fs"); +const path = require("path"); + +const LOG_ROOT = path.resolve(__dirname, "../Logs"); +const LOG_TYPES = ["Output", "Warn", "Error"]; + +function todayDate() { + return new Date().toISOString().split("T")[0]; +} + +function getLogPath(type, date = todayDate()) { + return path.join(LOG_ROOT, type, `${type}-${date}.log`); +} + +/** Prefer today's file; fall back to the newest log in the folder. */ +function resolveLogPath(type) { + const todayPath = getLogPath(type); + if (fs.existsSync(todayPath)) return todayPath; + + const dir = path.join(LOG_ROOT, type); + if (!fs.existsSync(dir)) return todayPath; + + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".log")) + .sort() + .reverse(); + + if (!files.length) return todayPath; + return path.join(dir, files[0]); +} + +function stripAnsi(text) { + return String(text).replace(/\x1b\[[0-9;]*m/g, ""); +} + +function parseLogLine(line, type) { + const trimmed = line.trimEnd(); + if (!trimmed) return null; + + const match = trimmed.match(/^\[([^\]]+)\]\s?(.*)$/); + if (match) { + return { + type, + timestamp: match[1], + message: stripAnsi(match[2]), + }; + } + + return { + type, + timestamp: null, + message: stripAnsi(trimmed), + }; +} + +function readFromByte(filePath, fromByte) { + if (!fs.existsSync(filePath)) { + return { lines: [], position: 0 }; + } + + const stat = fs.statSync(filePath); + if (stat.size === 0) { + return { lines: [], position: 0 }; + } + + const start = Math.max(0, Math.min(fromByte, stat.size)); + if (start >= stat.size) { + return { lines: [], position: stat.size }; + } + + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(stat.size - start); + fs.readSync(fd, buffer, 0, buffer.length, start); + fs.closeSync(fd); + + const text = buffer.toString("utf8"); + const lines = text.split(/\r?\n/).filter((l) => l.length > 0); + + return { lines, position: stat.size }; +} + +function readLastLines(filePath, maxLines) { + if (!fs.existsSync(filePath)) { + return { lines: [], position: 0 }; + } + + const stat = fs.statSync(filePath); + if (stat.size === 0) { + return { lines: [], position: 0 }; + } + + const chunkSize = Math.min(stat.size, 512 * 1024); + const start = Math.max(0, stat.size - chunkSize); + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(chunkSize); + fs.readSync(fd, buffer, 0, chunkSize, start); + fs.closeSync(fd); + + const lines = buffer + .toString("utf8") + .split(/\r?\n/) + .filter((l) => l.length > 0); + const slice = lines.slice(-maxLines); + + return { lines: slice, position: stat.size }; +} + +/** + * @param {string[]} types + * @param {Record} cursor byte offsets per type + * @param {number} initialLines lines to load when cursor is empty + */ +function fetchLogs(types, cursor = {}, initialLines = 300) { + const validTypes = types.filter((t) => LOG_TYPES.includes(t)); + const entries = []; + const nextCursor = { ...cursor }; + + for (const type of validTypes) { + const filePath = resolveLogPath(type); + const prevPos = cursor[type] ?? 0; + let lines = []; + let position = 0; + + if (!fs.existsSync(filePath)) { + nextCursor[type] = 0; + continue; + } + + if (prevPos === 0 && initialLines > 0) { + const tail = readLastLines(filePath, initialLines); + lines = tail.lines; + position = tail.position; + } else if (prevPos > 0) { + const chunk = readFromByte(filePath, prevPos); + lines = chunk.lines; + position = chunk.position; + } else { + // No tail requested and no cursor — skip content, anchor at EOF + position = fs.statSync(filePath).size; + } + + nextCursor[type] = position; + + for (const line of lines) { + const entry = parseLogLine(line, type); + if (entry) entries.push(entry); + } + } + + entries.sort((a, b) => { + const ta = a.timestamp ? Date.parse(a.timestamp) : 0; + const tb = b.timestamp ? Date.parse(b.timestamp) : 0; + return ta - tb; + }); + + return { entries, cursor: nextCursor }; +} + +function getLogMeta() { + const date = todayDate(); + return LOG_TYPES.map((type) => { + const filePath = resolveLogPath(type); + const exists = fs.existsSync(filePath) && fs.statSync(filePath).size > 0; + return { + type, + date, + file: exists ? path.basename(filePath) : null, + size: exists ? fs.statSync(filePath).size : 0, + }; + }); +} + +module.exports = { + LOG_ROOT, + LOG_TYPES, + fetchLogs, + getLogMeta, + getLogPath, + resolveLogPath, +}; diff --git a/Structures/ModuleStatus.js b/Structures/ModuleStatus.js new file mode 100644 index 0000000..d809c93 --- /dev/null +++ b/Structures/ModuleStatus.js @@ -0,0 +1,270 @@ +const fs = require("fs"); +const path = require("path"); + +const PLACEHOLDER_PATTERNS = [ + /ROLE_ID/i, + /CHANNEL_ID/i, + /CATEGORY_ID/i, + /TAG_ID/i, + /FORUM_CHANNEL_ID/i, + /BOT_TOKEN/i, + /MODEL_API_KEY/i, + /PASTEBIN_API_KEY/i, + /YOUR_/i, + /PUT_RANDOM_STRING/i, + /example\.com/i, +]; + +function isEmpty(value) { + if (value == null) return true; + if (typeof value === "boolean") return false; + if (typeof value === "number") return false; + return String(value).trim() === ""; +} + +function isPlaceholder(value) { + if (isEmpty(value)) return true; + const s = String(value).trim(); + return PLACEHOLDER_PATTERNS.some((p) => p.test(s)); +} + +function isDiscordId(value) { + return /^\d{17,20}$/.test(String(value).trim()); +} + +function isConfiguredId(value) { + return isDiscordId(value) && !isPlaceholder(value); +} + +function isValidToken(value) { + if (isPlaceholder(value)) return false; + const s = String(value).trim(); + return s.length >= 50 && s.includes("."); +} + +function scoreChecks(checks) { + const total = checks.length; + const passed = checks.filter((c) => c.ok).length; + + if (total === 0) { + return { status: "done", subtitle: "Ready", passed: 0, total: 0 }; + } + + if (passed === 0) { + const hint = checks.find((c) => !c.ok)?.hint ?? "Not configured yet"; + return { status: "todo", subtitle: hint, passed, total }; + } + + if (passed === total) { + return { status: "done", subtitle: "Configured and ready", passed, total }; + } + + const next = checks.find((c) => !c.ok); + return { + status: "progress", + subtitle: next?.hint ?? `${passed} of ${total} steps complete`, + passed, + total, + }; +} + +function evaluateTickets(supportbot, ticketPanel) { + const ticket = supportbot?.Ticket ?? {}; + const ticketType = String(ticket.TicketType || "channels").toLowerCase(); + + return scoreChecks([ + { + ok: ticketPanel?.Panel === true, + hint: "Enable the ticket panel", + }, + { + ok: isConfiguredId(ticket.TicketHome), + hint: "Set TicketHome channel ID", + }, + { + ok: + ticketType !== "channels" || + isConfiguredId(ticket.TicketChannelsCategory), + hint: "Set ticket category ID", + }, + { + ok: !isPlaceholder(ticketPanel?.Button?.Text), + hint: "Configure panel button text", + }, + ]); +} + +function evaluateSupportbot(supportbot) { + const roles = supportbot?.Roles?.StaffMember ?? {}; + const welcome = supportbot?.Welcome ?? {}; + + return scoreChecks([ + { ok: isValidToken(supportbot?.General?.Token), hint: "Add your bot token" }, + { + ok: + isConfiguredId(roles.Staff) || + isConfiguredId(roles.Admin) || + isConfiguredId(roles.Moderator), + hint: "Set staff role IDs", + }, + { + ok: !welcome.Enabled || isConfiguredId(welcome.Channel), + hint: "Set welcome channel ID", + }, + { + ok: !isPlaceholder(supportbot?.Activity?.Status), + hint: "Set bot activity status", + }, + ]); +} + +function evaluateTranscripts(supportbot, transcriptCount) { + const ticketReady = isConfiguredId(supportbot?.Ticket?.TicketHome); + + if (!ticketReady) { + // Ticket setup is tracked by the tickets module — avoid a misleading transcripts alert. + return { + status: "done", + subtitle: "Available after ticket channels are configured", + passed: 0, + total: 0, + }; + } + + if (transcriptCount > 0) { + return { + status: "done", + subtitle: `${transcriptCount} transcript${transcriptCount === 1 ? "" : "s"} saved`, + passed: 1, + total: 1, + }; + } + + return { + status: "done", + subtitle: "No transcripts yet — they are created when tickets close", + passed: 1, + total: 1, + }; +} + +function evaluateSuggestions(supportbot) { + const sug = supportbot?.Suggestions ?? {}; + const mode = String(sug.Mode || "Channel").toLowerCase(); + + const channelOk = + mode === "forum" + ? isConfiguredId(sug.ForumChannel) + : isConfiguredId(sug.Channel); + + return scoreChecks([ + { + ok: channelOk, + hint: + mode === "forum" + ? "Set suggestions forum channel" + : "Set suggestions channel ID", + }, + { + ok: !isPlaceholder(sug.UpvoteEmoji) && !isPlaceholder(sug.DownvoteEmoji), + hint: "Configure voting emojis", + }, + ]); +} + +function evaluateAi(supportbotAi) { + if (supportbotAi?.Enabled !== true) { + return { + status: "progress", + subtitle: "AI is disabled in config", + passed: 0, + total: 3, + }; + } + + return scoreChecks([ + { + ok: !isPlaceholder(supportbotAi?.General?.Model_API_Key), + hint: "Add your model API key", + }, + { + ok: isConfiguredId(supportbotAi?.Channels?.AIChannel), + hint: "Set AI channel ID", + }, + { + ok: !isPlaceholder(supportbotAi?.General?.Model), + hint: "Choose an AI model", + }, + ]); +} + +function evaluateCommands(commands) { + const entries = Object.entries(commands || {}).filter( + ([, v]) => v && typeof v === "object" && "Enabled" in v, + ); + + if (!entries.length) { + return { status: "todo", subtitle: "No commands configured", passed: 0, total: 0 }; + } + + const enabled = entries.filter(([, v]) => v.Enabled !== false).length; + const total = entries.length; + const ratio = enabled / total; + + if (ratio >= 0.85) { + return { + status: "done", + subtitle: `${enabled} of ${total} commands enabled`, + passed: enabled, + total, + }; + } + + if (enabled === 0) { + return { + status: "todo", + subtitle: "Enable slash commands", + passed: 0, + total, + }; + } + + return { + status: "progress", + subtitle: `${enabled} of ${total} commands enabled`, + passed: enabled, + total, + }; +} + +function countTranscripts() { + const dir = path.resolve(__dirname, "../Data/Transcripts"); + if (!fs.existsSync(dir)) return 0; + return fs + .readdirSync(dir) + .filter((f) => f.endsWith("-transcript.html")).length; +} + +function evaluateAllModules(configStore, options = {}) { + const supportbot = configStore.supportbot; + const ticketPanel = configStore.ticketPanel; + const supportbotAi = configStore.supportbotAi; + const commands = configStore.commands; + const transcriptCount = + options.transcriptCount ?? countTranscripts(); + + return { + tickets: evaluateTickets(supportbot, ticketPanel), + supportbot: evaluateSupportbot(supportbot), + transcripts: evaluateTranscripts(supportbot, transcriptCount), + suggestions: evaluateSuggestions(supportbot), + ai: evaluateAi(supportbotAi), + commands: evaluateCommands(commands), + }; +} + +module.exports = { + evaluateAllModules, + isPlaceholder, + isConfiguredId, +}; diff --git a/Structures/NotificationService.js b/Structures/NotificationService.js new file mode 100644 index 0000000..09e85e2 --- /dev/null +++ b/Structures/NotificationService.js @@ -0,0 +1,63 @@ +const notificationStore = require("./NotificationStore.js"); +const { fetchUpdateInfo } = require("./UpdateChecker.js"); + +function notifyBotRestart(success, errorMessage) { + if (success) { + return notificationStore.add({ + type: "bot", + title: "Bot restarted", + message: + "Discord bot reconnected with your latest config. API and dashboard stayed online.", + }); + } + + return notificationStore.add({ + type: "error", + title: "Bot failed to restart", + message: errorMessage || "Check the logs for details.", + href: "/logs", + }); +} + +function notifyUpdateAvailable(info) { + return notificationStore.add({ + id: "update-available", + type: "update", + title: "Update available", + message: `Version ${info.latest} is ready (you are on ${info.current}).`, + href: "/settings", + }); +} + +function notifyUpdateInstalled(message) { + return notificationStore.add({ + type: "update", + title: "Update installed", + message: + message || + "Files were updated. Restart your server (npm start) to finish applying changes.", + href: "/settings", + }); +} + +function clearUpdateAvailable() { + notificationStore.dismiss("update-available"); +} + +async function runUpdateCheck() { + const info = await fetchUpdateInfo(); + if (info.updateAvailable) { + notifyUpdateAvailable(info); + } else { + clearUpdateAvailable(); + } + return info; +} + +module.exports = { + notifyBotRestart, + notifyUpdateAvailable, + notifyUpdateInstalled, + clearUpdateAvailable, + runUpdateCheck, +}; diff --git a/Structures/NotificationStore.js b/Structures/NotificationStore.js new file mode 100644 index 0000000..b5876c2 --- /dev/null +++ b/Structures/NotificationStore.js @@ -0,0 +1,86 @@ +const MAX_NOTIFICATIONS = 50; + +/** @type {Map} */ +const byId = new Map(); +/** @type {string[]} */ +let order = []; + +/** + * @param {object} input + * @param {string} [input.id] Stable id replaces existing (e.g. update-available) + */ +function add(input) { + const id = + input.id || + `n-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const item = { + id, + type: input.type || "info", + title: input.title || "Notification", + message: input.message || "", + href: input.href || null, + createdAt: input.createdAt || Date.now(), + read: false, + }; + + if (byId.has(id)) { + order = order.filter((x) => x !== id); + } + + byId.set(id, item); + order.unshift(id); + + while (order.length > MAX_NOTIFICATIONS) { + const removed = order.pop(); + if (removed) byId.delete(removed); + } + + return item; +} + +function list() { + return order.map((id) => byId.get(id)).filter(Boolean); +} + +function unreadCount() { + let n = 0; + for (const item of byId.values()) { + if (!item.read) n++; + } + return n; +} + +function markRead(ids) { + if (ids === "all") { + for (const item of byId.values()) { + item.read = true; + } + return; + } + for (const id of ids) { + const item = byId.get(id); + if (item) item.read = true; + } +} + +function dismiss(id) { + if (!byId.has(id)) return false; + byId.delete(id); + order = order.filter((x) => x !== id); + return true; +} + +function clear() { + byId.clear(); + order = []; +} + +module.exports = { + add, + list, + unreadCount, + markRead, + dismiss, + clear, +}; diff --git a/Structures/TicketID.js b/Structures/TicketID.js index 7fb941e..d7e25a0 100644 --- a/Structures/TicketID.js +++ b/Structures/TicketID.js @@ -1,30 +1,46 @@ // SupportBot | Emerald Services // Ticket ID Structure -const db = require("./Database") +const db = require("./Database"); class TicketNumberID { static async pad() { - let num = await this.get() - let n = num + "" - await this.set(num + 1) - return n.length >= 4 ? n : new Array(4 - n.length + 1).join("0") + n + let num = await this.get(); + let n = num + ""; + await this.set(num + 1); + return n.length >= 4 ? n : new Array(4 - n.length + 1).join("0") + n; } static async get() { - const row = db.getTicketPanel() - if (!row) return await this.set(0) - return row.id + const settings = db.getSettings ? db.getSettings() : {}; + if (typeof settings.ticket_counter === "number") { + return settings.ticket_counter; + } + + const row = db.getTicketPanel ? db.getTicketPanel() : null; + if (row && typeof row.message_id === "string" && row.message_id.startsWith("panel-")) { + const parsed = parseInt(row.message_id.replace("panel-", ""), 10); + if (!isNaN(parsed)) return parsed; + } + + if (row && typeof row.id === "number" && row.id < 1000000) { + return row.id; + } + + return 0; } static async set(value) { - db.saveTicketPanel(`panel-${value}`, "none") - return value + const settings = db.getSettings ? db.getSettings() : {}; + settings.ticket_counter = value; + if (db.saveSettings) db.saveSettings(settings); + if (db.saveTicketPanel) db.saveTicketPanel(`panel-${value}`, "none"); + return value; } static async reset() { - return await this.set(0) + return await this.set(0); } } -module.exports = TicketNumberID +module.exports = TicketNumberID; diff --git a/Structures/TicketManager.js b/Structures/TicketManager.js index 85455f4..e860cf0 100644 --- a/Structures/TicketManager.js +++ b/Structures/TicketManager.js @@ -4,8 +4,9 @@ const Discord = require("discord.js"); const yaml = require("js-yaml"); const db = require("./Database.js"); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); +const supportbot = require("./ConfigStore").supportbot; +const msgconfig = require("./ConfigStore").messages; +const { createTranscriptHTML } = require("./TranscriptTemplate.js"); function escapeHtml(str = "") { return String(str) @@ -68,7 +69,7 @@ function renderAttachment(att) { if (type === "image") { return `

- 📎 ${name} + 📎 ${name}
${name}
@@ -79,7 +80,7 @@ function renderAttachment(att) { if (type === "video") { return `
- 📎 ${name} + 📎 ${name}