mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-11 01:36:16 +00:00
auto: add error ids for error reporting
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,3 +21,5 @@ accounts.main.jsonc
|
|||||||
.update-extract/
|
.update-extract/
|
||||||
.update-happened
|
.update-happened
|
||||||
.update-restart-count
|
.update-restart-count
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
// In-memory rate limiting for error reporting
|
// In-memory rate limiting for error reporting
|
||||||
const rateLimitMap = new Map()
|
const rateLimitMap = new Map()
|
||||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
const RATE_LIMIT_MAX_REQUESTS = 10
|
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) {
|
function isRateLimited(ip) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const record = rateLimitMap.get(ip)
|
const record = rateLimitMap.get(ip)
|
||||||
@@ -25,7 +30,6 @@ function isRateLimited(ip) {
|
|||||||
// Sanitize text to prevent Discord mention abuse
|
// Sanitize text to prevent Discord mention abuse
|
||||||
function sanitizeDiscordText(text) {
|
function sanitizeDiscordText(text) {
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
|
||||||
return String(text)
|
return String(text)
|
||||||
// Remove @everyone and @here mentions
|
// Remove @everyone and @here mentions
|
||||||
.replace(/@(everyone|here)/gi, '@\u200b$1')
|
.replace(/@(everyone|here)/gi, '@\u200b$1')
|
||||||
@@ -39,6 +43,38 @@ function sanitizeDiscordText(text) {
|
|||||||
.slice(0, 2000)
|
.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
|
// Vercel serverless handler
|
||||||
module.exports = async function handler(req, res) {
|
module.exports = async function handler(req, res) {
|
||||||
// CORS headers
|
// CORS headers
|
||||||
@@ -83,7 +119,20 @@ module.exports = async function handler(req, res) {
|
|||||||
const sanitizedPlatform = sanitizeDiscordText(payload.context?.platform || 'unknown')
|
const sanitizedPlatform = sanitizeDiscordText(payload.context?.platform || 'unknown')
|
||||||
const sanitizedNode = sanitizeDiscordText(payload.context?.nodeVersion || '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 = {
|
const embed = {
|
||||||
title: '🔴 Bot Error Report',
|
title: '🔴 Bot Error Report',
|
||||||
description: `\`\`\`\n${sanitizedError.slice(0, 1900)}\n\`\`\``,
|
description: `\`\`\`\n${sanitizedError.slice(0, 1900)}\n\`\`\``,
|
||||||
@@ -94,7 +143,7 @@ module.exports = async function handler(req, res) {
|
|||||||
{ name: 'Node', value: sanitizedNode, inline: true }
|
{ name: 'Node', value: sanitizedNode, inline: true }
|
||||||
],
|
],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
footer: { text: 'Community Error Reporting' }
|
footer: { text: `Community Error Reporting — Error ID: ${computedId}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sanitizedStack) {
|
if (sanitizedStack) {
|
||||||
@@ -113,8 +162,8 @@ module.exports = async function handler(req, res) {
|
|||||||
embeds: [embed]
|
embeds: [embed]
|
||||||
}, { timeout: 10000 })
|
}, { timeout: 10000 })
|
||||||
|
|
||||||
console.log('[ErrorReporting] Report sent successfully')
|
console.log(`[ErrorReporting] Report sent successfully (id=${computedId})`)
|
||||||
return res.json({ success: true, message: 'Error report received' })
|
return res.json({ success: true, message: 'Error report received', id: computedId })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ErrorReporting] Failed:', error)
|
console.error('[ErrorReporting] Failed:', error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createServer } from 'http'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { WebSocket, WebSocketServer } from 'ws'
|
import { WebSocket, WebSocketServer } from 'ws'
|
||||||
import { logEventEmitter } from '../util/notifications/Logger'
|
import { logEventEmitter } from '../util/notifications/Logger'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { apiRouter } from './routes'
|
import { apiRouter } from './routes'
|
||||||
import { DashboardLog, dashboardState } from './state'
|
import { DashboardLog, dashboardState } from './state'
|
||||||
|
|
||||||
@@ -94,6 +95,34 @@ export class DashboardServer {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Error reporting endpoint (community error collection)
|
// 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) => {
|
this.app.post('/api/report-error', this.apiLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const webhookUrl = process.env.DISCORD_ERROR_WEBHOOK_URL
|
const webhookUrl = process.env.DISCORD_ERROR_WEBHOOK_URL
|
||||||
@@ -107,7 +136,22 @@ export class DashboardServer {
|
|||||||
return res.status(400).json({ error: 'Invalid payload' })
|
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 = {
|
const embed = {
|
||||||
title: '🔴 Bot Error Report',
|
title: '🔴 Bot Error Report',
|
||||||
description: `\`\`\`\n${String(payload.error).slice(0, 1900)}\n\`\`\``,
|
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 }
|
{ name: 'Node', value: String(payload.context?.nodeVersion || 'unknown'), inline: true }
|
||||||
],
|
],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
footer: { text: 'Community Error Reporting' }
|
footer: { text: `Community Error Reporting — Error ID: ${computedId}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.stack) {
|
if (payload.stack) {
|
||||||
@@ -134,8 +178,8 @@ export class DashboardServer {
|
|||||||
embeds: [embed]
|
embeds: [embed]
|
||||||
}, { timeout: 10000 })
|
}, { timeout: 10000 })
|
||||||
|
|
||||||
dashLog('Error report sent to Discord', 'log')
|
dashLog(`Error report sent to Discord (id=${computedId})`, 'log')
|
||||||
return res.json({ success: true, message: 'Error report received' })
|
return res.json({ success: true, message: 'Error report received', id: computedId })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashLog(`Error reporting failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
dashLog(`Error reporting failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { Config } from '../../interface/Config'
|
import { Config } from '../../interface/Config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,7 @@ const ERROR_REPORTING_HARD_DISABLED = false
|
|||||||
interface ErrorReportPayload {
|
interface ErrorReportPayload {
|
||||||
error: string
|
error: string
|
||||||
stack?: string
|
stack?: string
|
||||||
|
id?: string
|
||||||
context: {
|
context: {
|
||||||
version: string
|
version: string
|
||||||
platform: string
|
platform: string
|
||||||
@@ -40,6 +42,42 @@ function sanitizeSensitiveText(text: string): string {
|
|||||||
return SANITIZE_PATTERNS.reduce((acc, [pattern, replace]) => acc.replace(pattern, replace), text)
|
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<string, unknown>; additionalContext?: Record<string, unknown> }): 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)
|
* 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,
|
error: sanitizedMessage,
|
||||||
stack: sanitizedStack,
|
stack: sanitizedStack,
|
||||||
context,
|
context,
|
||||||
additionalContext: Object.keys(sanitizedAdditionalContext).length > 0 ? sanitizedAdditionalContext : undefined
|
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<string, unknown>; additionalContext?: Record<string, unknown> })
|
||||||
|
|
||||||
|
return {
|
||||||
|
...partialPayload,
|
||||||
|
id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user