import express from 'express' import rateLimit from 'express-rate-limit' import fs from 'fs' import { createServer } from 'http' import path from 'path' import { WebSocket, WebSocketServer } from 'ws' import { logEventEmitter } from '../util/notifications/Logger' import crypto from 'crypto' import { apiRouter } from './routes' import { DashboardLog, dashboardState } from './state' // Dashboard logging helper (uses events, NOT interception) const dashLog = (message: string, type: 'log' | 'warn' | 'error' = 'log'): void => { const logEntry: DashboardLog = { timestamp: new Date().toISOString(), level: type, platform: 'MAIN', title: 'DASHBOARD', message } // Add to console console.log(`[${logEntry.timestamp}] [${logEntry.platform}] [${logEntry.title}] ${message}`) // Add to dashboard state dashboardState.addLog(logEntry) } const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000 const HOST = process.env.DASHBOARD_HOST || '127.0.0.1' export class DashboardServer { private app: express.Application private server: ReturnType private wss: WebSocketServer private clients: Set = new Set() private heartbeatInterval?: NodeJS.Timeout private dashboardLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs for dashboard UI standardHeaders: true, legacyHeaders: false, }) private apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 300, // reasonable cap for API interactions standardHeaders: true, legacyHeaders: false, }) constructor() { this.app = express() this.server = createServer(this.app) this.wss = new WebSocketServer({ server: this.server }) this.setupMiddleware() this.setupRoutes() this.setupWebSocket() this.setupLogEventListener() // FIXED: Use event listener instead of function interception this.setupStateListener() } private setupStateListener(): void { // Listen to dashboard state changes and broadcast to all clients dashboardState.addChangeListener((type, data) => { this.broadcastUpdate(type, data) }) } private setupMiddleware(): void { this.app.use(express.json()) // Disable caching for all static files this.app.use((req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private') res.set('Pragma', 'no-cache') res.set('Expires', '0') next() }) this.app.use('/assets', express.static(path.join(__dirname, '../../assets'), { etag: false, maxAge: 0 })) this.app.use(express.static(path.join(__dirname, '../../public'), { etag: false, maxAge: 0 })) } private setupRoutes(): void { this.app.use('/api', this.apiLimiter, apiRouter) // Health check this.app.get('/health', (_req, res) => { res.json({ status: 'ok', uptime: process.uptime() }) }) // Error reporting endpoint (community error collection) // Adds deterministic error ID and simple in-memory dedupe to avoid spam const dashboardErrorCache = new Map() const DASHBOARD_ERROR_TTL_MS = 60 * 60 * 1000 // 1 hour function normalizeForId(text: string) { if (!text) return '' let t = String(text) t = t.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/g, '') t = t.replace(/0x[0-9a-fA-F]+/g, '') t = t.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s:]*)/g, '[PATH]') t = t.replace(/:\d+(?::\d+)?/g, '') return t.replace(/\s+/g, ' ').trim() } function computeErrorId(payload: any) { const parts: string[] = [] parts.push(normalizeForId(payload.error || '')) if (payload.stack) parts.push(normalizeForId(payload.stack)) const ctx = payload.context || {} const ctxKeys = Object.keys(ctx).filter(k => k !== 'timestamp').sort() for (const k of ctxKeys) parts.push(`${k}=${String(ctx[k])}`) const add = payload.additionalContext || {} const addKeys = Object.keys(add).sort() for (const k of addKeys) parts.push(`${k}=${String(add[k])}`) const canonical = parts.join('|') return crypto.createHash('sha256').update(canonical).digest('hex').slice(0, 12) } this.app.post('/api/report-error', this.apiLimiter, async (req, res) => { try { const webhookUrl = process.env.DISCORD_ERROR_WEBHOOK_URL if (!webhookUrl) { dashLog('Error reporting: DISCORD_ERROR_WEBHOOK_URL not configured', 'warn') return res.status(503).json({ error: 'Error reporting service unavailable' }) } const payload = req.body if (!payload?.error) { return res.status(400).json({ error: 'Invalid payload' }) } // Compute deterministic ID and dedupe similar errors for a short window const sanitizedError = String(payload.error || '') const sanitizedStack = payload.stack ? String(payload.stack) : undefined const computedId = computeErrorId({ error: sanitizedError, stack: sanitizedStack, context: payload.context, additionalContext: payload.additionalContext }) const now = Date.now() const existing = dashboardErrorCache.get(computedId) if (existing && existing.expires > now) { existing.count = (existing.count || 0) + 1 dashboardErrorCache.set(computedId, existing) dashLog(`Duplicate error suppressed (id=${computedId})`, 'warn') return res.json({ success: true, duplicate: true, id: computedId }) } dashboardErrorCache.set(computedId, { expires: now + DASHBOARD_ERROR_TTL_MS, count: 1 }) // Build Discord embed with ID included const embed = { title: '🔴 Bot Error Report', description: `\`\`\`\n${String(payload.error).slice(0, 1900)}\n\`\`\``, color: 0xdc143c, fields: [ { name: 'Version', value: String(payload.context?.version || 'unknown'), inline: true }, { name: 'Platform', value: String(payload.context?.platform || 'unknown'), inline: true }, { name: 'Node', value: String(payload.context?.nodeVersion || 'unknown'), inline: true } ], timestamp: new Date().toISOString(), footer: { text: `Community Error Reporting — Error ID: ${computedId}` } } if (payload.stack) { const stackLines = String(payload.stack).split('\n').slice(0, 15).join('\n') embed.fields.push({ name: 'Stack Trace', value: `\`\`\`\n${stackLines.slice(0, 1000)}\n\`\`\``, inline: false }) } // Send to Discord (use native axios, not AxiosClient) const axios = (await import('axios')).default await axios.post(webhookUrl, { username: 'Microsoft Rewards Bot', avatar_url: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/assets/logo.png', embeds: [embed] }, { timeout: 10000 }) dashLog(`Error report sent to Discord (id=${computedId})`, 'log') return res.json({ success: true, message: 'Error report received', id: computedId }) } catch (error) { dashLog(`Error reporting failed: ${error instanceof Error ? error.message : String(error)}`, 'error') return res.status(500).json({ error: 'Failed to send error report' }) } }) // Serve dashboard UI this.app.get('/', this.dashboardLimiter, (_req, res) => { const indexPath = path.join(__dirname, '../../public/index.html') // Force no cache on HTML files res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private') res.set('Pragma', 'no-cache') res.set('Expires', '0') if (fs.existsSync(indexPath)) { res.sendFile(indexPath) } else { res.status(200).send(` Dashboard - API Only Mode

Dashboard API Active

Frontend UI not found. API endpoints are available:

`) } }) } private setupWebSocket(): void { this.wss.on('connection', (ws: WebSocket) => { const tracked = ws as WebSocket & { isAlive?: boolean } tracked.isAlive = true this.clients.add(tracked) dashLog('WebSocket client connected') tracked.on('pong', () => { tracked.isAlive = true }) tracked.on('close', () => { this.clients.delete(tracked) dashLog('WebSocket client disconnected') }) tracked.on('error', (error) => { dashLog(`WebSocket error: ${error instanceof Error ? error.message : String(error)}`, 'error') }) // Send initial data on connect const recentLogs = dashboardState.getLogs(100) const status = dashboardState.getStatus() const accounts = dashboardState.getAccounts() tracked.send(JSON.stringify({ type: 'init', data: { logs: recentLogs, status, accounts } })) }) // Heartbeat to drop dead connections and keep memory clean this.heartbeatInterval = setInterval(() => { for (const client of this.clients) { const tracked = client as WebSocket & { isAlive?: boolean } if (tracked.isAlive === false) { tracked.terminate() this.clients.delete(tracked) continue } tracked.isAlive = false try { tracked.ping() } catch (error) { dashLog(`WebSocket ping error: ${error instanceof Error ? error.message : String(error)}`, 'error') tracked.terminate() this.clients.delete(tracked) } } }, 30000) } /** * FIXED: Listen to log events instead of intercepting Logger.log function * This prevents WebSocket disconnection issues and function interception conflicts */ private setupLogEventListener(): void { logEventEmitter.on('log', (logEntry: DashboardLog) => { // Add to dashboard state and broadcast dashboardState.addLog(logEntry) this.broadcastUpdate('log', { log: logEntry }) }) dashLog('Log event listener active') } public broadcastUpdate(type: string, data: unknown): void { const payload = JSON.stringify({ type, data }) for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) { try { client.send(payload) } catch (error) { dashLog(`Error broadcasting update: ${error instanceof Error ? error.message : String(error)}`, 'error') } } } } public start(): void { this.server.listen(PORT, HOST, () => { dashLog(`Server running on http://${HOST}:${PORT}`) dashLog('WebSocket ready for live logs') }) } public stop(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval) this.heartbeatInterval = undefined } this.wss.close() this.server.close() dashLog('Server stopped') } } export function startDashboardServer(): DashboardServer { const server = new DashboardServer() server.start() return server }