import axios from 'axios' import crypto from 'crypto' import fs from 'fs' import path from 'path' import { DISCORD } from '../../constants' import { Config } from '../../interface/Config' interface DiscordEmbed { title: string description: string color: number fields: Array<{ name: string; value: string; inline: boolean }> timestamp: string footer: { text: string; icon_url: string } } interface ErrorReportPayload { error: string stack?: string context: { version: string platform: string arch: string nodeVersion: string timestamp: string botMode?: string // DESKTOP, MOBILE, or MAIN } } const SANITIZE_PATTERNS: Array<[RegExp, string]> = [ [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[EMAIL_REDACTED]'], [/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*/g, '[PATH_REDACTED]'], [/\/(?:home|Users)\/[^/\s]+(?:\/[^/\s]+)*/g, '[PATH_REDACTED]'], [/\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, '[IP_REDACTED]'], [/\b[A-Za-z0-9_-]{20,}\b/g, '[TOKEN_REDACTED]'] ] function sanitizeSensitiveText(text: string): string { return SANITIZE_PATTERNS.reduce((acc, [pattern, replace]) => acc.replace(pattern, replace), text) } /** * Build the Discord payload from error and context (sanitizes content) * Returns null if error should be filtered (prevents sending) */ function buildDiscordPayload(config: Config, error: Error | string, additionalContext?: Record): { username: string; avatar_url?: string; embeds: DiscordEmbed[] } | null { const errorMessage = error instanceof Error ? error.message : String(error) const sanitizedForLogging = sanitizeSensitiveText(errorMessage) if (!shouldReportError(errorMessage)) { process.stderr.write(`[ErrorReporting] Filtered error (expected/benign): ${sanitizedForLogging.substring(0, 100)}\n`) return null // FIXED: Return null instead of sending dummy message } const errorStack = error instanceof Error ? error.stack : undefined const sanitizedMessage = sanitizeSensitiveText(errorMessage) const sanitizedStack = errorStack ? sanitizeSensitiveText(errorStack).split('\n').slice(0, 10).join('\n') : undefined const payloadContext: ErrorReportPayload['context'] = { version: getProjectVersion(), platform: process.platform, arch: process.arch, nodeVersion: process.version, timestamp: new Date().toISOString(), botMode: (additionalContext?.platform as string) || 'UNKNOWN' } if (additionalContext) { for (const [key, value] of Object.entries(additionalContext)) { if (typeof value === 'string') { (payloadContext as Record)[key] = sanitizeSensitiveText(value) } else { (payloadContext as Record)[key] = value } } } const osPlatform = (() => { // Basic platform formatting switch (payloadContext.platform) { case 'win32': return '🪟 Windows' case 'darwin': return '🍎 macOS' case 'linux': return '🐧 Linux' default: return payloadContext.platform } })() const embed: DiscordEmbed = { title: '🐛 Automatic Error Report', description: `\`\`\`js\n${sanitizedMessage.slice(0, 700)}\n\`\`\``, color: DISCORD.COLOR_RED, fields: [ { name: '📦 Version', value: payloadContext.version === 'unknown' ? '⚠️ Unknown (check package.json)' : `v${payloadContext.version}`, inline: true }, { name: '🤖 Bot Mode', value: payloadContext.botMode || 'UNKNOWN', inline: true }, { name: '💻 OS Platform', value: `${osPlatform} ${payloadContext.arch}`, inline: true }, { name: '⚙️ Node.js', value: payloadContext.nodeVersion, inline: true }, { name: '🕐 Timestamp', value: new Date(payloadContext.timestamp).toLocaleString('en-US', { timeZone: 'UTC', timeZoneName: 'short' }), inline: false } ], timestamp: payloadContext.timestamp, footer: { text: 'Automatic error reporting • Non-sensitive data only', icon_url: DISCORD.AVATAR_URL } } if (sanitizedStack) { const truncated = sanitizedStack.slice(0, 900) const wasTruncated = sanitizedStack.length > 900 embed.fields.push({ name: '📋 Stack Trace' + (wasTruncated ? ' (truncated for display)' : ''), value: `\`\`\`js\n${truncated}${wasTruncated ? '\n... (see full trace in logs)' : ''}\n\`\`\``, inline: false }) } if (additionalContext) { for (const [key, value] of Object.entries(additionalContext)) { if (embed.fields.length < 25) embed.fields.push({ name: key, value: sanitizeSensitiveText(String(value)).slice(0, 1024), inline: true }) } } return { username: 'Microsoft-Rewards-Bot Error Reporter', avatar_url: DISCORD.AVATAR_URL, embeds: [embed] } } /** * Simple obfuscation/deobfuscation for webhook URL * Not for security, just to avoid easy scraping */ /** * Obfuscation helpers * - If `ERROR_WEBHOOK_KEY` is provided, `obfuscateWebhookUrl` will return `ENC:` * where the payload is AES-256-GCM(iv|tag|ciphertext). * - Otherwise it returns `B64:` (simple base64) to avoid storing plain URLs. */ const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ function getEncryptionKey(): Buffer | null { const keyStr = process.env.ERROR_WEBHOOK_KEY || '' if (!keyStr) return null return crypto.createHash('sha256').update(keyStr, 'utf8').digest() } export function obfuscateWebhookUrl(url: string): string { const key = getEncryptionKey() if (!key) { return 'B64:' + Buffer.from(url, 'utf8').toString('base64') } try { const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) const ciphertext = Buffer.concat([cipher.update(url, 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() const out = Buffer.concat([iv, tag, ciphertext]).toString('base64') return 'ENC:' + out } catch { // Fallback to base64 if encryption fails return 'B64:' + Buffer.from(url, 'utf8').toString('base64') } } export function deobfuscateWebhookUrl(encoded: string): string { const trimmed = (encoded || '').trim() if (!trimmed) return '' // ENC: prefixed encrypted value (AES-256-GCM) if (trimmed.startsWith('ENC:')) { const payload = trimmed.slice(4) const key = getEncryptionKey() if (!key) return '' try { const buf = Buffer.from(payload, 'base64') const iv = buf.slice(0, 12) const tag = buf.slice(12, 28) const ciphertext = buf.slice(28) const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) decipher.setAuthTag(tag) const res = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8') return res } catch { return '' } } // B64: prefixed base64 value if (trimmed.startsWith('B64:')) { try { return Buffer.from(trimmed.slice(4), 'base64').toString('utf8') } catch { return '' } } // Backwards compatibility: raw base64 without prefix if (BASE64_REGEX.test(trimmed)) { try { return Buffer.from(trimmed, 'base64').toString('utf8') } catch { return '' } } return '' } /** * Check if an error should be reported (filter false positives and user configuration errors) */ function shouldReportError(errorMessage: string): boolean { const lowerMessage = errorMessage.toLowerCase() // List of patterns that indicate user configuration errors (not reportable bugs) const userConfigPatterns = [ /accounts\.jsonc.*not found/i, /config\.jsonc.*not found/i, /invalid.*credentials/i, /login.*failed/i, /authentication.*failed/i, /proxy.*connection.*failed/i, /totp.*invalid/i, /2fa.*failed/i, /incorrect.*password/i, /account.*suspended/i, /account.*banned/i, /no.*accounts.*enabled/i, /invalid.*configuration/i, /missing.*required.*field/i, /port.*already.*in.*use/i, /eaddrinuse/i, // Rebrowser-playwright expected errors (benign, non-fatal) /rebrowser-patches.*cannot get world/i, /session closed.*rebrowser/i, /addScriptToEvaluateOnNewDocument.*session closed/i, // User auth issues (not bot bugs) /password.*incorrect/i, /email.*not.*found/i, /account.*locked/i ] // Don't report user configuration errors for (const pattern of userConfigPatterns) { if (pattern.test(lowerMessage)) { return false } } // List of patterns that indicate expected/handled errors (not bugs) const expectedErrorPatterns = [ /no.*points.*to.*earn/i, /already.*completed/i, /activity.*not.*available/i, /daily.*limit.*reached/i, /quest.*not.*found/i, /promotion.*expired/i, // Playwright expected errors (page lifecycle, navigation, timeouts) /target page.*context.*browser.*been closed/i, /page.*has been closed/i, /context.*has been closed/i, /browser.*has been closed/i, /execution context was destroyed/i, /frame was detached/i, /navigation.*cancelled/i, /timeout.*exceeded/i, /waiting.*failed.*timeout/i, /net::ERR_ABORTED/i, /net::ERR_CONNECTION_REFUSED/i, /net::ERR_NAME_NOT_RESOLVED/i ] // Don't report expected/handled errors for (const pattern of expectedErrorPatterns) { if (pattern.test(lowerMessage)) { return false } } // Report everything else (genuine bugs) return true } // Internal webhooks stored obfuscated to avoid having raw URLs in the repository. // We store them as `B64:` entries. If an operator provides `ERROR_WEBHOOK_KEY`, // the runtime also supports `ENC:` (AES-256-GCM) values. // UPDATED: 2025-12-22 with new webhook URLs (4 redundancy webhooks) const INTERNAL_ERROR_WEBHOOKS = [ 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRiWE5QbHd1NTVuTEpjenZzWjB1ODlQSm9Lb1NpYzFZaUxqWQ==', 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOUdvOWhqalZFODZPeUFuX0NkRkVORGd1MG81bVl5MVdubllZc3I1LWxBOG12', 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWEp0NW90N2R6bGoweTJDTFpFVTdJaHhSdzdSazNNUjhoaHhidEJvQTdmbktpV2RuMFJaMC1VN3FBSUxV', 'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcllvN1lWNEdUaVU5ejhoQ1FoWXdvNkwyTQ==' ] // Track disabled webhooks as encoded entries during this execution (in-memory and persisted) // Stored form maps encoded string -> timestamp const disabledEncodedWebhooks = new Map() let lastSuccessfulEncoded: string | null = null const DISABLED_WEBHOOKS_FILE = path.join(process.cwd(), 'sessions', 'disabled-webhooks.json') const DISABLED_WEBHOOK_TTL = 60 * 60 * 1000 // 1 hour function loadDisabledWebhooksFromDisk() { try { if (fs.existsSync(DISABLED_WEBHOOKS_FILE)) { const raw = fs.readFileSync(DISABLED_WEBHOOKS_FILE, 'utf8') const parsed = JSON.parse(raw) as { disabled?: Record, lastSuccess?: string } if (parsed.disabled) { const cutoff = Date.now() - DISABLED_WEBHOOK_TTL for (const [encoded, timestamp] of Object.entries(parsed.disabled)) { if (typeof timestamp === 'number' && timestamp >= cutoff) { disabledEncodedWebhooks.set(encoded, timestamp) } } } if (parsed.lastSuccess && typeof parsed.lastSuccess === 'string') { lastSuccessfulEncoded = parsed.lastSuccess } } } catch { // ignore } } function saveDisabledWebhooksToDisk() { try { const dir = path.dirname(DISABLED_WEBHOOKS_FILE) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) const payload = { disabled: Object.fromEntries(disabledEncodedWebhooks), lastSuccess: lastSuccessfulEncoded } fs.writeFileSync(DISABLED_WEBHOOKS_FILE, JSON.stringify(payload, null, 2), 'utf8') } catch { // ignore } } function pruneExpiredDisabledWebhooks() { const now = Date.now() for (const [encoded, timestamp] of Array.from(disabledEncodedWebhooks.entries())) { if (now - timestamp > DISABLED_WEBHOOK_TTL) { disabledEncodedWebhooks.delete(encoded) } } } function isTemporarilyDisabled(encoded: string): boolean { const ts = disabledEncodedWebhooks.get(encoded) if (!ts) return false if (Date.now() - ts > DISABLED_WEBHOOK_TTL) { disabledEncodedWebhooks.delete(encoded) return false } return true } function markTemporarilyDisabled(encoded: string): void { disabledEncodedWebhooks.set(encoded, Date.now()) } // Load persisted state at module init loadDisabledWebhooksFromDisk() /** * Disable error reporting temporarily for this execution * Used when webhook is deleted (404) - no need to keep trying */ export function disableErrorReportingTemporary(): void { // Disable all internal webhooks for this execution (persist encoded markers) for (const encoded of INTERNAL_ERROR_WEBHOOKS) { markTemporarilyDisabled(encoded) } saveDisabledWebhooksToDisk() process.stderr.write('[ErrorReporting] ⚠️ Disabled internal webhooks temporarily for this execution (webhook(s) may no longer be available)\n') } /** * Send error report to Discord webhook for community contribution * Only sends non-sensitive error information to help improve the project */ export async function sendErrorReport( config: Config, error: Error | string, additionalContext?: Record ): Promise { // Error reporting not available as 12/26/2025 because of vulnerabilities // View here: https://ptb.discord.com/channels/1418201715009912866/1418201717098418249/1454198384813412534 return // Check if error reporting is enabled if (config.errorReporting?.enabled === false) { process.stderr.write('[ErrorReporting] Disabled in config (errorReporting.enabled = false)\n') return } // Log that error reporting is enabled process.stderr.write('[ErrorReporting] Enabled, processing error...\n') try { pruneExpiredDisabledWebhooks() // Build candidate webhook list: // - If config provides webhooks, prefer them (accepts plain or base64-encoded values) // - Else fall back to internal hardcoded list const candidateEncodedWebhooks: string[] = [] if (Array.isArray(config.errorReporting?.webhooks) && config.errorReporting.webhooks.length > 0) { for (const entry of config.errorReporting!.webhooks!) { if (typeof entry === 'string' && entry.trim()) { // If the string looks like a full URL, obfuscate it to keep downstream decoding simple if (entry.startsWith('http')) { candidateEncodedWebhooks.push(obfuscateWebhookUrl(entry)) } else { // Assume already encoded (base64) candidateEncodedWebhooks.push(entry) } } } } if (candidateEncodedWebhooks.length === 0) { candidateEncodedWebhooks.push(...INTERNAL_ERROR_WEBHOOKS) } // Attempt each webhook in order until one succeeds let lastError: unknown = null let sent = false // Prefer the last successful webhook if available if (lastSuccessfulEncoded) { const idx = candidateEncodedWebhooks.indexOf(lastSuccessfulEncoded) if (idx > 0) { candidateEncodedWebhooks.splice(idx, 1) candidateEncodedWebhooks.unshift(lastSuccessfulEncoded) } } for (const encoded of candidateEncodedWebhooks) { const webhookUrl = deobfuscateWebhookUrl(encoded) if (!webhookUrl || !webhookUrl.startsWith('https://discord.com/api/webhooks/')) { continue } if (isTemporarilyDisabled(encoded)) { process.stderr.write(`[ErrorReporting] Skipping disabled webhook: ${webhookUrl}\n`) continue } process.stderr.write(`[ErrorReporting] Trying webhook: ${webhookUrl}\n`) try { // FIXED: Check if payload is null (filtered error) const payload = buildDiscordPayload(config, error, additionalContext) if (!payload) { process.stderr.write('[ErrorReporting] Skipping webhook send (error was filtered)\n') sent = true // Mark as "sent" to prevent fallback error message break } const response = await axios.post(webhookUrl, payload, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 }) process.stderr.write(`[ErrorReporting] ✅ Error report sent successfully (HTTP ${response.status})\n`) // mark success and persist lastSuccessfulEncoded = encoded saveDisabledWebhooksToDisk() sent = true break } catch (webhookError) { lastError = webhookError let httpStatus: number | null = null if (webhookError && typeof webhookError === 'object' && 'response' in webhookError) { const axiosError = webhookError as { response?: { status: number } } httpStatus = axiosError.response?.status || null } if (httpStatus === 404) { markTemporarilyDisabled(encoded) saveDisabledWebhooksToDisk() process.stderr.write(`[ErrorReporting] ❌ Webhook not found (404): ${webhookUrl} - disabling for this run\n`) continue } if (httpStatus === 401 || httpStatus === 403) { markTemporarilyDisabled(encoded) saveDisabledWebhooksToDisk() process.stderr.write(`[ErrorReporting] ❌ Webhook auth failed (HTTP ${httpStatus}): ${webhookUrl} - disabling for this run\n`) continue } if (httpStatus && httpStatus >= 500) { process.stderr.write(`[ErrorReporting] ⚠️ Discord server error (HTTP ${httpStatus}) for webhook ${webhookUrl} - will try next webhook\n`) continue } const webhookErrorMessage = webhookError instanceof Error ? webhookError.message : String(webhookError) process.stderr.write(`[ErrorReporting] ❌ Failed to send error report to ${webhookUrl}: ${sanitizeSensitiveText(webhookErrorMessage)}\n`) // try next webhook (small delay to avoid burst) await new Promise((r) => setTimeout(r, 200 + Math.floor(Math.random() * 300))) } } if (!sent) { // If none succeeded, fall back to logging the failure const lastErrorMessage = lastError instanceof Error ? lastError.message : String(lastError) process.stderr.write('[ErrorReporting] ❌ All webhook attempts failed. Last error: ' + sanitizeSensitiveText(lastErrorMessage) + '\n') } return } catch (webhookError) { // Enhanced error handling - detect specific HTTP errors let errorMsg = '' let httpStatus: number | null = null if (webhookError && typeof webhookError === 'object' && 'response' in webhookError) { const axiosError = webhookError as { response?: { status: number } } httpStatus = axiosError.response?.status || null } // Handle specific error cases if (httpStatus === 404) { // Webhook was deleted - disable error reporting for this execution errorMsg = 'Webhook not found (404) - was it deleted? Disabling error reporting for this run.' disableErrorReportingTemporary() process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`) return } if (httpStatus === 401 || httpStatus === 403) { // Authentication/authorization error errorMsg = `Webhook authentication failed (HTTP ${httpStatus}) - check if webhook token is valid` disableErrorReportingTemporary() process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`) return } if (httpStatus && httpStatus >= 500) { // Server error - may be temporary, log but don't disable errorMsg = `Discord server error (HTTP ${httpStatus}) - will retry on next error` process.stderr.write(`[ErrorReporting] ⚠️ ${errorMsg}\n`) return } // Generic error message if (!errorMsg) { errorMsg = webhookError instanceof Error ? webhookError.message : String(webhookError) } // Log detailed error for debugging process.stderr.write(`[ErrorReporting] ❌ Failed to send error report: ${sanitizeSensitiveText(errorMsg)}\n`) // If it's a network error, provide additional context if (webhookError instanceof Error && (webhookError.message.includes('ENOTFOUND') || webhookError.message.includes('ECONNREFUSED'))) { process.stderr.write('[ErrorReporting] Network issue detected - check your internet connection\n') } } } /** * Get project version from package.json * FIXED: Use path.join to correctly resolve package.json location in both dev and production */ function getProjectVersion(): string { try { // Try multiple possible paths (dev and compiled) const possiblePaths = [ path.join(__dirname, '../../../package.json'), // From dist/util/notifications/ path.join(__dirname, '../../package.json'), // From src/util/notifications/ path.join(process.cwd(), 'package.json') // From project root ] for (const pkgPath of possiblePaths) { try { if (fs.existsSync(pkgPath)) { const raw = fs.readFileSync(pkgPath, 'utf-8') const pkg = JSON.parse(raw) as { version?: string } if (pkg.version) { return pkg.version } } } catch { // Try next path continue } } return 'unknown' } catch { return 'unknown' } }