feat: Add automatic error reporting feature with configuration options and documentation

This commit is contained in:
2025-11-09 17:36:07 +01:00
parent 2a168bd06a
commit 56aacd3667
9 changed files with 417 additions and 28 deletions

View File

@@ -138,7 +138,7 @@
// Dashboard
"dashboard": {
"enabled": true, // Auto-start dashboard with bot (default: false)
"enabled": false, // Auto-start dashboard with bot (default: false)
"port": 3000, // Dashboard port (default: 3000)
"host": "127.0.0.1" // Bind address (default: 127.0.0.1, localhost only for security)
},
@@ -155,6 +155,12 @@
"autoUpdateAccounts": false // Update accounts file from remote (NEVER recommended, keeps your accounts)
},
// Error Reporting (Community Contribution)
"errorReporting": {
"enabled": true, // Automatically report errors to help improve the project (no sensitive data sent)
"webhookUrl": "aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQzNzExMTk2MjM5NDY4OTYyOS90bHZHS1phSDktckppcjR0blpLU1pwUkhTM1liZU40dlpudUN2NTBrNU1wQURZUlBuSG5aNk15YkFsZ0Y1UUZvNktIXw==" // Obfuscated webhook URL (base64 encoded)
},
// Scheduling (automatic task scheduling)
// When enabled=true, the bot will automatically configure your system scheduler on first run.
// This works on Windows (Task Scheduler), Linux/Raspberry Pi (cron), and macOS (cron).

View File

@@ -353,21 +353,13 @@ export class MicrosoftRewardsBot {
// Check if all workers have exited
if (this.activeWorkers === 0) {
// All workers done -> send conclusion (if enabled), run optional auto-update, then exit
// All workers done -> send conclusion and exit (update check moved to startup)
(async () => {
try {
await this.sendConclusion(this.accountSummaries)
} catch (e) {
log('main', 'CONCLUSION', `Failed to send conclusion: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
try {
const updateCode = await this.runAutoUpdate()
if (updateCode === 0) {
log('main', 'UPDATE', '✅ Update successful - next run will use new version', 'log', 'green')
}
} catch (e) {
log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
})()
@@ -636,22 +628,8 @@ export class MicrosoftRewardsBot {
process.send({ type: 'summary', data: this.accountSummaries })
}
} else {
// Single process mode -> build and send conclusion directly
// Single process mode -> build and send conclusion directly (update check moved to startup)
await this.sendConclusion(this.accountSummaries)
// After conclusion, run optional auto-update
const updateResult = await this.runAutoUpdate().catch((e) => {
log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn')
return 1 // Error code
})
// If update was successful (code 0), restart the script to use the new version
// This is critical for cron jobs - they need to apply updates immediately
if (updateResult === 0) {
log('main', 'UPDATE', '✅ Update successful - restarting with new version...', 'log', 'green')
// On Raspberry Pi/Linux with cron, just exit - cron will handle next run
// No need to restart immediately, next scheduled run will use new code
log('main', 'UPDATE', 'Next scheduled run will use the updated code', 'log')
}
}
process.exit()
}
@@ -773,7 +751,7 @@ export class MicrosoftRewardsBot {
*
* @returns Exit code (0 = success, non-zero = error)
*/
private async runAutoUpdate(): Promise<number> {
async runAutoUpdate(): Promise<number> {
const upd = this.config.update
if (!upd) return 0
@@ -983,6 +961,29 @@ async function main(): Promise<void> {
const bootstrap = async () => {
try {
// Check for updates BEFORE initializing and running tasks
try {
const updateResult = await rewardsBot.runAutoUpdate().catch((e) => {
log('main', 'UPDATE', `Auto-update check failed: ${e instanceof Error ? e.message : String(e)}`, 'warn')
return -1
})
if (updateResult === 0) {
log('main', 'UPDATE', '✅ Update successful - restarting with new version...', 'log', 'green')
// Restart the process with the same arguments
const { spawn } = await import('child_process')
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: 'inherit'
})
child.unref()
process.exit(0)
}
} catch (updateError) {
log('main', 'UPDATE', `Update check failed (continuing): ${updateError instanceof Error ? updateError.message : String(updateError)}`, 'warn')
}
await rewardsBot.initialize()
await rewardsBot.run()
} catch (e) {

View File

@@ -30,6 +30,7 @@ export interface Config {
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
dashboard?: ConfigDashboard; // NEW: Local web dashboard for monitoring and control
scheduling?: ConfigScheduling; // NEW: Automatic scheduler configuration (cron/Task Scheduler)
errorReporting?: ConfigErrorReporting; // NEW: Automatic error reporting to community webhook
}
export interface ConfigSaveFingerprint {
@@ -211,3 +212,8 @@ export interface ConfigScheduling {
highestPrivileges?: boolean; // request highest privileges
};
}
export interface ConfigErrorReporting {
enabled?: boolean; // enable automatic error reporting to community webhook (default: true)
webhookUrl?: string; // obfuscated Discord webhook URL for error reports
}

View File

@@ -0,0 +1,239 @@
import axios from 'axios'
import { DISCORD } from '../constants'
import { Config } from '../interface/Config'
interface ErrorReportPayload {
error: string
stack?: string
context: {
version: string
platform: string
arch: string
nodeVersion: string
timestamp: string
}
}
/**
* Simple obfuscation/deobfuscation for webhook URL
* Not for security, just to avoid easy scraping
*/
export function obfuscateWebhookUrl(url: string): string {
return Buffer.from(url).toString('base64')
}
export function deobfuscateWebhookUrl(encoded: string): string {
try {
return Buffer.from(encoded, 'base64').toString('utf-8')
} catch {
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
]
// 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
]
// Don't report expected/handled errors
for (const pattern of expectedErrorPatterns) {
if (pattern.test(lowerMessage)) {
return false
}
}
// Report everything else (genuine bugs)
return true
}
/**
* 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<string, unknown>
): Promise<void> {
// Check if error reporting is enabled and URL is configured
if (!config.errorReporting?.enabled) return
if (!config.errorReporting?.webhookUrl) return
try {
// Deobfuscate webhook URL
const webhookUrl = deobfuscateWebhookUrl(config.errorReporting.webhookUrl)
if (!webhookUrl || !webhookUrl.startsWith('https://discord.com/api/webhooks/')) {
return
}
const errorMessage = error instanceof Error ? error.message : String(error)
// Filter out false positives and user configuration errors
if (!shouldReportError(errorMessage)) {
return
}
const errorStack = error instanceof Error ? error.stack : undefined
// Sanitize error message and stack - remove any potential sensitive data
const sanitize = (text: string): string => {
return text
// Remove email addresses
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[EMAIL_REDACTED]')
// Remove absolute paths (Windows and Unix)
.replace(/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*/g, '[PATH_REDACTED]')
.replace(/\/(?:home|Users)\/[^/\s]+(?:\/[^/\s]+)*/g, '[PATH_REDACTED]')
// Remove IP addresses
.replace(/\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, '[IP_REDACTED]')
// Remove potential tokens/keys (sequences of 20+ alphanumeric chars)
.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[TOKEN_REDACTED]')
}
const sanitizedMessage = sanitize(errorMessage)
const sanitizedStack = errorStack ? sanitize(errorStack).split('\n').slice(0, 10).join('\n') : undefined
// Build context payload with system information
const payload: ErrorReportPayload = {
error: sanitizedMessage,
stack: sanitizedStack,
context: {
version: getProjectVersion(),
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
timestamp: new Date().toISOString()
}
}
// Add additional context if provided (also sanitized)
if (additionalContext) {
const sanitizedContext: Record<string, unknown> = {}
for (const [key, value] of Object.entries(additionalContext)) {
if (typeof value === 'string') {
sanitizedContext[key] = sanitize(value)
} else {
sanitizedContext[key] = value
}
}
Object.assign(payload.context, sanitizedContext)
}
// Build Discord embed
const embed = {
title: '🐛 Automatic Error Report',
description: `\`\`\`\n${sanitizedMessage.slice(0, 500)}\n\`\`\``,
color: DISCORD.COLOR_RED,
fields: [
{
name: '📦 Version',
value: payload.context.version,
inline: true
},
{
name: '💻 Platform',
value: `${payload.context.platform} (${payload.context.arch})`,
inline: true
},
{
name: '⚙️ Node.js',
value: payload.context.nodeVersion,
inline: true
}
],
timestamp: payload.context.timestamp,
footer: {
text: 'Automatic error reporting - Thank you for contributing!',
icon_url: DISCORD.AVATAR_URL
}
}
// Add stack trace field if available (truncated)
if (sanitizedStack) {
embed.fields.push({
name: '📋 Stack Trace (truncated)',
value: `\`\`\`\n${sanitizedStack.slice(0, 800)}\n\`\`\``,
inline: false
})
}
// Add additional context fields if provided
if (additionalContext) {
for (const [key, value] of Object.entries(additionalContext)) {
if (embed.fields.length < 25) { // Discord limit
embed.fields.push({
name: key,
value: String(value).slice(0, 1024),
inline: true
})
}
}
}
const discordPayload = {
username: 'Microsoft-Rewards-Bot Error Reporter',
avatar_url: DISCORD.AVATAR_URL,
embeds: [embed]
}
// Send to webhook with timeout
await axios.post(webhookUrl, discordPayload, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
})
} catch (webhookError) {
// Silent fail - we don't want error reporting to break the application
// Only log to stderr to avoid recursion
process.stderr.write(`[ErrorReporting] Failed to send error report: ${webhookError}\n`)
}
}
/**
* Get project version from package.json
*/
function getProjectVersion(): string {
try {
// Dynamic import for package.json
// eslint-disable-next-line @typescript-eslint/no-var-requires
const packageJson = require('../../package.json') as { version?: string }
return packageJson.version || 'unknown'
} catch {
return 'unknown'
}
}

View File

@@ -2,6 +2,7 @@ import axios from 'axios'
import chalk from 'chalk'
import { DISCORD } from '../constants'
import { sendErrorReport } from './ErrorReportingWebhook'
import { loadConfig } from './Load'
import { Ntfy } from './Ntfy'
@@ -282,8 +283,24 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
process.stderr.write(`[Logger] Failed to enqueue webhook log: ${error}\n`)
}
// Return an Error when logging an error so callers can `throw log(...)`
// Automatic error reporting to community webhook (fire and forget)
if (type === 'error') {
return new Error(cleanStr)
const errorObj = new Error(cleanStr)
// Send error report asynchronously without blocking
Promise.resolve().then(async () => {
try {
await sendErrorReport(configData, errorObj, {
title,
platform: platformText
})
} catch {
// Silent fail - error reporting should never break the application
}
}).catch(() => {
// Catch any promise rejection silently
})
return errorObj
}
}