diff --git a/.gitignore b/.gitignore index c77e5ca..85d9674 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ accounts.main.jsonc .update-extract/ .update-happened .update-restart-count +.env +.env.* \ No newline at end of file diff --git a/api/report-error.js b/api/report-error.js index 9cfa379..c859b74 100644 --- a/api/report-error.js +++ b/api/report-error.js @@ -1,10 +1,15 @@ const axios = require('axios') +const crypto = require('crypto') // In-memory rate limiting for error reporting const rateLimitMap = new Map() const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute const RATE_LIMIT_MAX_REQUESTS = 10 +// In-memory deduplication cache for identical errors +const errorCache = new Map() +const ERROR_TTL_MS = 60 * 60 * 1000 // 1 hour dedupe window + function isRateLimited(ip) { const now = Date.now() const record = rateLimitMap.get(ip) @@ -25,7 +30,6 @@ function isRateLimited(ip) { // Sanitize text to prevent Discord mention abuse function sanitizeDiscordText(text) { if (!text) return '' - return String(text) // Remove @everyone and @here mentions .replace(/@(everyone|here)/gi, '@\u200b$1') @@ -39,6 +43,38 @@ function sanitizeDiscordText(text) { .slice(0, 2000) } +function normalizeForId(text) { + if (!text) return '' + let t = String(text) + // Remove ISO timestamps + t = t.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/g, '') + // Remove hex pointers + t = t.replace(/0x[0-9a-fA-F]+/g, '') + // Replace absolute paths + t = t.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s:]*)/g, '[PATH]') + // Remove :line:col + t = t.replace(/:\d+(?::\d+)?/g, '') + // Collapse whitespace + return t.replace(/\s+/g, ' ').trim() +} + +function computeErrorId(payload) { + const parts = [] + 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) +} + // Vercel serverless handler module.exports = async function handler(req, res) { // CORS headers @@ -83,7 +119,20 @@ module.exports = async function handler(req, res) { const sanitizedPlatform = sanitizeDiscordText(payload.context?.platform || 'unknown') const sanitizedNode = sanitizeDiscordText(payload.context?.nodeVersion || 'unknown') - // Build Discord embed + // Compute deterministic error id and check dedupe cache + const computedId = computeErrorId({ error: sanitizedError, stack: sanitizedStack, context: payload.context, additionalContext: payload.additionalContext }) + const now = Date.now() + const existing = errorCache.get(computedId) + if (existing && existing.expires > now) { + existing.count = (existing.count || 0) + 1 + errorCache.set(computedId, existing) + console.log(`[ErrorReporting] Duplicate error (id=${computedId}) suppressed; count=${existing.count}`) + return res.json({ success: true, duplicate: true, id: computedId }) + } + // Store in cache to prevent spam of same error within window + errorCache.set(computedId, { expires: now + ERROR_TTL_MS, count: 1 }) + + // Build Discord embed with Error ID included in footer const embed = { title: '🔴 Bot Error Report', description: `\`\`\`\n${sanitizedError.slice(0, 1900)}\n\`\`\``, @@ -94,7 +143,7 @@ module.exports = async function handler(req, res) { { name: 'Node', value: sanitizedNode, inline: true } ], timestamp: new Date().toISOString(), - footer: { text: 'Community Error Reporting' } + footer: { text: `Community Error Reporting — Error ID: ${computedId}` } } if (sanitizedStack) { @@ -113,8 +162,8 @@ module.exports = async function handler(req, res) { embeds: [embed] }, { timeout: 10000 }) - console.log('[ErrorReporting] Report sent successfully') - return res.json({ success: true, message: 'Error report received' }) + console.log(`[ErrorReporting] Report sent successfully (id=${computedId})`) + return res.json({ success: true, message: 'Error report received', id: computedId }) } catch (error) { console.error('[ErrorReporting] Failed:', error) diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index ebb6829..b60e2ad 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -5,6 +5,7 @@ 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' @@ -94,6 +95,34 @@ export class DashboardServer { }) // 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 @@ -107,7 +136,22 @@ export class DashboardServer { return res.status(400).json({ error: 'Invalid payload' }) } - // Build Discord embed + // 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\`\`\``, @@ -118,7 +162,7 @@ export class DashboardServer { { name: 'Node', value: String(payload.context?.nodeVersion || 'unknown'), inline: true } ], timestamp: new Date().toISOString(), - footer: { text: 'Community Error Reporting' } + footer: { text: `Community Error Reporting — Error ID: ${computedId}` } } if (payload.stack) { @@ -134,8 +178,8 @@ export class DashboardServer { embeds: [embed] }, { timeout: 10000 }) - dashLog('Error report sent to Discord', 'log') - return res.json({ success: true, message: 'Error report received' }) + 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') diff --git a/src/util/notifications/ErrorReportingWebhook.ts b/src/util/notifications/ErrorReportingWebhook.ts index 48711a5..5615f6d 100644 --- a/src/util/notifications/ErrorReportingWebhook.ts +++ b/src/util/notifications/ErrorReportingWebhook.ts @@ -1,6 +1,7 @@ import axios from 'axios' import fs from 'fs' import path from 'path' +import crypto from 'crypto' import { Config } from '../../interface/Config' /** @@ -12,6 +13,7 @@ const ERROR_REPORTING_HARD_DISABLED = false interface ErrorReportPayload { error: string stack?: string + id?: string context: { version: string platform: string @@ -40,6 +42,42 @@ function sanitizeSensitiveText(text: string): string { return SANITIZE_PATTERNS.reduce((acc, [pattern, replace]) => acc.replace(pattern, replace), text) } +function normalizeForId(text: string): string { + if (!text) return '' + + // Remove ISO timestamps + let t = String(text).replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/g, '') + // Remove hex addresses / pointers + t = t.replace(/0x[0-9a-fA-F]+/g, '') + // Replace absolute paths with placeholder + t = t.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s:]*)/g, '[PATH]') + // Remove line/column numbers in stack traces (file:line:col) + t = t.replace(/:\d+(?:[:]\d+)?/g, '') + // Collapse whitespace + t = t.replace(/\s+/g, ' ').trim() + return t +} + +function computeErrorId(payload: { error: string; stack?: string; context?: Record; additionalContext?: Record }): string { + const parts: string[] = [] + parts.push(normalizeForId(payload.error || '')) + if (payload.stack) parts.push(normalizeForId(payload.stack)) + + // Include context keys except timestamp to keep ID deterministic across runs/machines + const ctx = payload.context || {} + const ctxEntries = Object.keys(ctx).filter(k => k !== 'timestamp').sort().map(k => `${k}=${String(ctx[k])}`) + parts.push(...ctxEntries) + + // Additional context sorted + const add = payload.additionalContext || {} + const addEntries = Object.keys(add).sort().map(k => `${k}=${String(add[k])}`) + parts.push(...addEntries) + + const canonical = parts.join('|') + const hash = crypto.createHash('sha256').update(canonical).digest('hex') + return hash.slice(0, 12) +} + /** * Check if an error should be reported (filter false positives and user configuration errors) */ @@ -154,12 +192,20 @@ function buildErrorReportPayload(error: Error | string, additionalContext?: Reco } } - return { + const partialPayload = { error: sanitizedMessage, stack: sanitizedStack, context, additionalContext: Object.keys(sanitizedAdditionalContext).length > 0 ? sanitizedAdditionalContext : undefined } + + // Compute deterministic ID (exclude timestamp inside computeErrorId) + const id = computeErrorId(partialPayload as { error: string; stack?: string; context?: Record; additionalContext?: Record }) + + return { + ...partialPayload, + id + } } /**