diff --git a/src/constants.ts b/src/constants.ts index 5e0e4e5..6457550 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,7 +14,7 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: number): number { const raw = process.env[key] if (!raw) return defaultValue - + const parsed = Number(raw) if (!Number.isFinite(parsed)) { queueMicrotask(() => { @@ -26,7 +26,7 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: num }) return defaultValue } - + if (parsed < min || parsed > max) { queueMicrotask(() => { import('./util/Logger').then(({ log }) => { @@ -37,7 +37,7 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: num }) return defaultValue } - + return parsed } @@ -115,4 +115,9 @@ export const DISCORD = { COLOR_GRAY: 0x95A5A6, WEBHOOK_USERNAME: 'Microsoft-Rewards-Bot', AVATAR_URL: 'https://raw.githubusercontent.com/Obsidian-wtf/Microsoft-Rewards-Bot/main/assets/logo.png' +} as const + +export const LOGGER_CLEANUP = { + BUFFER_MAX_AGE_MS: TIMEOUTS.ONE_HOUR, + BUFFER_CLEANUP_INTERVAL_MS: TIMEOUTS.TEN_MINUTES } as const \ No newline at end of file diff --git a/src/flows/DesktopFlow.ts b/src/flows/DesktopFlow.ts index 858e568..70ad0af 100644 --- a/src/flows/DesktopFlow.ts +++ b/src/flows/DesktopFlow.ts @@ -12,7 +12,7 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' -import { createBrowserInstance } from '../util/BrowserFactory' +import { closeBrowserSafely, createBrowserInstance } from '../util/BrowserFactory' import { handleCompromisedMode } from './FlowUtils' export interface DesktopFlowResult { @@ -51,12 +51,12 @@ export class DesktopFlow { */ async run(account: Account): Promise { this.bot.log(false, 'DESKTOP-FLOW', 'Starting desktop automation flow') - + // IMPROVED: Use centralized browser factory to eliminate duplication const browser = await createBrowserInstance(this.bot, account.proxy, account.email) - + let keepBrowserOpen = false - + try { this.bot.homePage = await browser.newPage() @@ -129,19 +129,15 @@ export class DesktopFlow { // Fetch points BEFORE closing (avoid page closed reload error) const after = await this.bot.browser.func.getCurrentPoints().catch(() => initial) - + return { initialPoints: initial, collectedPoints: (after - initial) || 0 } } finally { if (!keepBrowserOpen) { - try { - await this.bot.browser.func.closeBrowser(browser, account.email) - } catch (closeError) { - const message = closeError instanceof Error ? closeError.message : String(closeError) - this.bot.log(false, 'DESKTOP-FLOW', `Failed to close desktop context: ${message}`, 'warn') - } + // IMPROVED: Use centralized browser close utility to eliminate duplication + await closeBrowserSafely(this.bot, browser, account.email, false) } } } diff --git a/src/flows/MobileFlow.ts b/src/flows/MobileFlow.ts index 69b2d18..fc9b1f2 100644 --- a/src/flows/MobileFlow.ts +++ b/src/flows/MobileFlow.ts @@ -13,7 +13,7 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' -import { createBrowserInstance } from '../util/BrowserFactory' +import { closeBrowserSafely, createBrowserInstance } from '../util/BrowserFactory' import { MobileRetryTracker } from '../util/MobileRetryTracker' import { handleCompromisedMode } from './FlowUtils' @@ -57,13 +57,13 @@ export class MobileFlow { retryTracker = new MobileRetryTracker(this.bot.config.searchSettings.retryMobileSearchAmount) ): Promise { this.bot.log(true, 'MOBILE-FLOW', 'Starting mobile automation flow') - + // IMPROVED: Use centralized browser factory to eliminate duplication const browser = await createBrowserInstance(this.bot, account.proxy, account.email) - + let keepBrowserOpen = false let browserClosed = false - + try { this.bot.homePage = await browser.newPage() @@ -71,19 +71,25 @@ export class MobileFlow { // Login into MS Rewards, then respect compromised mode await this.bot.login.login(this.bot.homePage, account.email, account.password, account.totp) - + if (this.bot.compromisedModeActive) { const reason = this.bot.compromisedReason || 'security-issue' const result = await handleCompromisedMode(this.bot, account.email, reason, true) keepBrowserOpen = result.keepBrowserOpen return { initialPoints: 0, collectedPoints: 0 } } - + const accessToken = await this.bot.login.getMobileAccessToken(this.bot.homePage, account.email, account.totp) await this.bot.browser.func.goHome(this.bot.homePage) const data = await this.bot.browser.func.getDashboardData() - const initialPoints = data.userStatus.availablePoints || 0 + + // FIXED: Log warning when availablePoints is missing instead of silently defaulting + const initialPoints = data.userStatus.availablePoints + if (initialPoints === undefined || initialPoints === null) { + this.bot.log(true, 'MOBILE-FLOW', 'Warning: availablePoints is undefined/null, defaulting to 0. This may indicate dashboard data issues.', 'warn') + } + const safeInitialPoints = initialPoints ?? 0 const browserEarnablePoints = await this.bot.browser.func.getBrowserEarnablePoints() const appEarnablePoints = await this.bot.browser.func.getAppEarnablePoints(accessToken) @@ -102,11 +108,11 @@ export class MobileFlow { this.bot.log(true, 'MOBILE-FLOW', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow') return { - initialPoints: initialPoints, + initialPoints: safeInitialPoints, collectedPoints: 0 } } - + // Do daily check in if (this.bot.config.workers.doDailyCheckIn) { await this.bot.activities.doDailyCheckIn(accessToken, data) @@ -146,13 +152,9 @@ export class MobileFlow { this.bot.log(true, 'MOBILE-FLOW', `Attempt ${attempt}/${maxMobileRetries}: Unable to complete mobile searches, bad User-Agent? Increase search delay? Retrying...`, 'log', 'yellow') // Close mobile browser before retrying to release resources - try { - await this.bot.browser.func.closeBrowser(browser, account.email) - browserClosed = true - } catch (closeError) { - const message = closeError instanceof Error ? closeError.message : String(closeError) - this.bot.log(true, 'MOBILE-FLOW', `Failed to close mobile context before retry: ${message}`, 'warn') - } + // IMPROVED: Use centralized browser close utility + await closeBrowserSafely(this.bot, browser, account.email, true) + browserClosed = true // Create a new browser and try again with the same tracker return await this.run(account, retryTracker) @@ -165,21 +167,17 @@ export class MobileFlow { const afterPointAmount = await this.bot.browser.func.getCurrentPoints() - this.bot.log(true, 'MOBILE-FLOW', `The script collected ${afterPointAmount - initialPoints} points today`) + this.bot.log(true, 'MOBILE-FLOW', `The script collected ${afterPointAmount - safeInitialPoints} points today`) return { - initialPoints: initialPoints, - collectedPoints: (afterPointAmount - initialPoints) || 0 + initialPoints: safeInitialPoints, + collectedPoints: (afterPointAmount - safeInitialPoints) || 0 } } finally { if (!keepBrowserOpen && !browserClosed) { - try { - await this.bot.browser.func.closeBrowser(browser, account.email) - browserClosed = true - } catch (closeError) { - const message = closeError instanceof Error ? closeError.message : String(closeError) - this.bot.log(true, 'MOBILE-FLOW', `Failed to close mobile context: ${message}`, 'warn') - } + // IMPROVED: Use centralized browser close utility to eliminate duplication + await closeBrowserSafely(this.bot, browser, account.email, true) + browserClosed = true } } } diff --git a/src/index.ts b/src/index.ts index c3aeca0..30fb827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { log } from './util/Logger' import { MobileRetryTracker } from './util/MobileRetryTracker' import { QueryDiversityEngine } from './util/QueryDiversityEngine' import { StartupValidator } from './util/StartupValidator' -import { formatDetailedError, shortErrorMessage, Util } from './util/Utils' +import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/Utils' import { Activities } from './functions/Activities' import { Login } from './functions/Login' @@ -92,7 +92,7 @@ export class MicrosoftRewardsBot { async initialize() { this.accounts = loadAccounts() - + // Run comprehensive startup validation const validator = new StartupValidator() try { @@ -103,9 +103,9 @@ export class MicrosoftRewardsBot { log('main', 'VALIDATION', `Fatal validation error: ${errorMsg}`, 'error') throw error // Re-throw to stop execution } - + // Validation passed - continue with initialization - + // Initialize job state if (this.config.jobState?.enabled !== false) { this.accountJobState = new JobState(this.config) @@ -146,25 +146,25 @@ export class MicrosoftRewardsBot { private async promptResetJobState(): Promise { // Check if auto-reset is enabled in config (for scheduled tasks) if (this.config.jobState?.autoResetOnComplete === true) { - log('main','TASK','Auto-reset enabled (jobState.autoResetOnComplete=true) - resetting and rerunning all accounts', 'log', 'green') + log('main', 'TASK', 'Auto-reset enabled (jobState.autoResetOnComplete=true) - resetting and rerunning all accounts', 'log', 'green') return true } // Check environment variable override const envAutoReset = process.env.REWARDS_AUTO_RESET_JOBSTATE if (envAutoReset === '1' || envAutoReset?.toLowerCase() === 'true') { - log('main','TASK','Auto-reset enabled (REWARDS_AUTO_RESET_JOBSTATE) - resetting and rerunning all accounts', 'log', 'green') + log('main', 'TASK', 'Auto-reset enabled (REWARDS_AUTO_RESET_JOBSTATE) - resetting and rerunning all accounts', 'log', 'green') return true } // Detect non-interactive environments more reliably - const isNonInteractive = !process.stdin.isTTY || - process.env.CI === 'true' || - process.env.DOCKER === 'true' || - process.env.SCHEDULED_TASK === 'true' - + const isNonInteractive = !process.stdin.isTTY || + process.env.CI === 'true' || + process.env.DOCKER === 'true' || + process.env.SCHEDULED_TASK === 'true' + if (isNonInteractive) { - log('main','TASK','Non-interactive environment detected - keeping job state (set jobState.autoResetOnComplete=true to auto-rerun)', 'warn') + log('main', 'TASK', 'Non-interactive environment detected - keeping job state (set jobState.autoResetOnComplete=true to auto-rerun)', 'warn') return false } @@ -184,7 +184,7 @@ export class MicrosoftRewardsBot { private resetAllJobStates(): void { if (!this.accountJobState) return - + const jobStateDir = this.accountJobState.getJobStateDir() if (!fs.existsSync(jobStateDir)) return @@ -225,9 +225,9 @@ export class MicrosoftRewardsBot { } private printBanner() { if (this.config.clusters > 1 && !cluster.isPrimary) return - + const version = this.getVersion() - + log('main', 'BANNER', `Microsoft Rewards Bot v${version}`) log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`) } @@ -246,7 +246,7 @@ export class MicrosoftRewardsBot { } return DEFAULT_VERSION } - + // Return summaries (used when clusters==1) public getSummaries() { return this.accountSummaries @@ -256,13 +256,13 @@ export class MicrosoftRewardsBot { log('main', 'MAIN-PRIMARY', 'Primary process started') const totalAccounts = this.accounts.length - + // Validate accounts exist if (totalAccounts === 0) { log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn') process.exit(0) } - + // If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers. const workerCount = Math.min(this.config.clusters, totalAccounts) const accountChunks = this.utils.chunkArray(this.accounts, workerCount) @@ -275,17 +275,17 @@ export class MicrosoftRewardsBot { for (let i = 0; i < workerCount; i++) { const worker = cluster.fork() const chunk = accountChunks[i] || [] - + // Validate chunk has accounts if (chunk.length === 0) { log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn') } - + // Store chunk mapping for crash recovery if (worker.id) { workerChunkMap.set(worker.id, chunk) } - + // FIXED: Proper type checking before calling send if (worker.send && typeof worker.send === 'function') { worker.send({ chunk }) @@ -298,7 +298,7 @@ export class MicrosoftRewardsBot { }) } - cluster.on('exit', (worker: Worker, code: number) => { + cluster.on('exit', (worker: Worker, code: number) => { this.activeWorkers -= 1 log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') @@ -309,20 +309,20 @@ export class MicrosoftRewardsBot { const attempts = (worker as { _restartAttempts?: number })._restartAttempts || 0 if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) { (worker as { _restartAttempts?: number })._restartAttempts = attempts + 1 - log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn') - + log('main', 'CRASH-RECOVERY', `Respawning worker (attempt ${attempts + 1})`, 'warn') + const originalChunk = workerChunkMap.get(worker.id) const newW = cluster.fork() - + if (originalChunk && originalChunk.length > 0 && newW.id) { (newW as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk }) workerChunkMap.set(newW.id, originalChunk) workerChunkMap.delete(worker.id) - log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`) + log('main', 'CRASH-RECOVERY', `Assigned ${originalChunk.length} account(s) to respawned worker`) } else { - log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker', 'warn') + log('main', 'CRASH-RECOVERY', 'Warning: Could not reassign accounts to respawned worker', 'warn') } - + newW.on('message', (msg: unknown) => { // IMPROVED: Using type-safe interface and type guard if (isWorkerMessage(msg)) { @@ -350,20 +350,20 @@ export class MicrosoftRewardsBot { private runWorker() { log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`) - // Receive the chunk of accounts from the master - ;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => { - const passes = this.config.passesPerRun ?? 1 - for (let pass = 1; pass <= passes; pass++) { - if (passes > 1) { - log('main', 'MAIN-WORKER', `Starting pass ${pass}/${passes}`) + // Receive the chunk of accounts from the master + ; (process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => { + const passes = this.config.passesPerRun ?? 1 + for (let pass = 1; pass <= passes; pass++) { + if (passes > 1) { + log('main', 'MAIN-WORKER', `Starting pass ${pass}/${passes}`) + } + await this.runTasks(chunk, pass, passes) + if (pass < passes) { + log('main', 'MAIN-WORKER', `Completed pass ${pass}/${passes}. Waiting before next pass...`) + await this.utils.wait(TIMEOUTS.ONE_MINUTE) + } } - await this.runTasks(chunk, pass, passes) - if (pass < passes) { - log('main', 'MAIN-WORKER', `Completed pass ${pass}/${passes}. Waiting before next pass...`) - await this.utils.wait(TIMEOUTS.ONE_MINUTE) - } - } - }) + }) } private async runTasks(accounts: Account[], currentPass: number = 1, totalPasses: number = 1) { @@ -371,65 +371,64 @@ export class MicrosoftRewardsBot { // BUT skip this check for multi-pass runs (passes > 1) OR if not on first pass const accountDayKey = this.utils.getFormattedDate() const allCompleted = accounts.every(acc => this.shouldSkipAccount(acc.email, accountDayKey)) - + // Only check completion on first pass and if not doing multiple passes if (allCompleted && accounts.length > 0 && currentPass === 1 && totalPasses === 1) { - log('main','TASK',`All accounts already completed on ${accountDayKey}`, 'warn', 'yellow') + log('main', 'TASK', `All accounts already completed on ${accountDayKey}`, 'warn', 'yellow') const shouldReset = await this.promptResetJobState() if (shouldReset) { this.resetAllJobStates() - log('main','TASK','Job state reset - proceeding with all accounts', 'log', 'green') + log('main', 'TASK', 'Job state reset - proceeding with all accounts', 'log', 'green') } else { - log('main','TASK','Keeping existing job state - exiting', 'log') + log('main', 'TASK', 'Keeping existing job state - exiting', 'log') return } } else if (allCompleted && accounts.length > 0 && currentPass > 1) { // Multi-pass mode: clear job state for this pass to allow re-running - log('main','TASK',`Pass ${currentPass}/${totalPasses}: Clearing job state to allow account re-run`, 'log', 'cyan') + log('main', 'TASK', `Pass ${currentPass}/${totalPasses}: Clearing job state to allow account re-run`, 'log', 'cyan') this.resetAllJobStates() } for (const account of accounts) { // If a global standby is active due to security/banned, stop processing further accounts if (this.globalStandby.active) { - log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow') + log('main', 'SECURITY', `Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow') break } // Optional global stop after first ban if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) { - log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn') + log('main', 'TASK', `Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`, 'warn') break } const currentDayKey = this.utils.getFormattedDate() // Note: shouldSkipAccount already returns false for multi-pass runs (passesPerRun > 1) if (this.shouldSkipAccount(account.email, currentDayKey)) { - log('main','TASK',`Skipping account ${account.email}: already completed on ${currentDayKey} (job-state resume)`, 'warn') + log('main', 'TASK', `Skipping account ${account.email}: already completed on ${currentDayKey} (job-state resume)`, 'warn') continue } - + // Log pass info for multi-pass runs if (totalPasses > 1) { - log('main','TASK',`[Pass ${currentPass}/${totalPasses}] Processing account ${account.email}`, 'log', 'cyan') + log('main', 'TASK', `[Pass ${currentPass}/${totalPasses}] Processing account ${account.email}`, 'log', 'cyan') } // Reset compromised state per account this.compromisedModeActive = false this.compromisedReason = undefined - + // If humanization allowed windows are configured, wait until within a window try { const windows: string[] | undefined = this.config?.humanization?.allowedWindows if (Array.isArray(windows) && windows.length > 0) { const waitMs = this.computeWaitForAllowedWindow(windows) if (waitMs > 0) { - log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn') + log('main', 'HUMANIZATION', `Waiting ${Math.ceil(waitMs / 1000)}s until next allowed window before starting ${account.email}`, 'warn') await new Promise(r => setTimeout(r, waitMs)) } } - } catch {/* ignore */} + } catch {/* ignore */ } this.currentAccountEmail = account.email - this.currentAccountRecoveryEmail = (typeof account.recoveryEmail === 'string' && account.recoveryEmail.trim() !== '') - ? account.recoveryEmail.trim() - : undefined + // IMPROVED: Use centralized recovery email validation utility + this.currentAccountRecoveryEmail = normalizeRecoveryEmail(account.recoveryEmail) const runNumber = (this.accountRunCounts.get(account.email) ?? 0) + 1 this.accountRunCounts.set(account.email, runNumber) log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`) @@ -466,54 +465,54 @@ export class MicrosoftRewardsBot { if (this.config.parallel) { const mobileInstance = new MicrosoftRewardsBot(true) mobileInstance.axios = this.axios - + // IMPROVED: Shared state to track desktop issues for early mobile abort consideration let desktopDetectedIssue = false - + // Run both and capture results with detailed logging const desktopPromise = this.Desktop(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') + log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`, 'error') const bd = detectBanReason(e) if (bd.status) { desktopDetectedIssue = true // Track issue for logging - banned.status = true; banned.reason = bd.reason.substring(0,200) + banned.status = true; banned.reason = bd.reason.substring(0, 200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null }) const mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') + log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`, 'error') const bd = detectBanReason(e) if (bd.status) { - banned.status = true; banned.reason = bd.reason.substring(0,200) + banned.status = true; banned.reason = bd.reason.substring(0, 200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null }) const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise]) - + // Log if desktop detected issue (helps identify when both flows ran despite ban) if (desktopDetectedIssue) { log('main', 'TASK', `Desktop detected security issue for ${account.email} during parallel execution. Future enhancement: implement AbortController for early mobile cancellation.`, 'warn') } - + // Handle desktop result if (desktopResult.status === 'fulfilled' && desktopResult.value) { desktopInitial = desktopResult.value.initialPoints desktopCollected = desktopResult.value.collectedPoints } else if (desktopResult.status === 'rejected') { - log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error') + log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`, 'error') errors.push(formatFullError('desktop-rejected', desktopResult.reason, verbose)) } - + // Handle mobile result if (mobileResult.status === 'fulfilled' && mobileResult.value) { mobileInitial = mobileResult.value.initialPoints mobileCollected = mobileResult.value.collectedPoints } else if (mobileResult.status === 'rejected') { - log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error') + log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`, 'error') errors.push(formatFullError('mobile-rejected', mobileResult.reason, verbose)) } } else { @@ -521,10 +520,10 @@ export class MicrosoftRewardsBot { this.isMobile = false const desktopResult = await this.Desktop(account).catch(e => { const msg = e instanceof Error ? e.message : String(e) - log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') + log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`, 'error') const bd = detectBanReason(e) if (bd.status) { - banned.status = true; banned.reason = bd.reason.substring(0,200) + banned.status = true; banned.reason = bd.reason.substring(0, 200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null @@ -538,10 +537,10 @@ export class MicrosoftRewardsBot { this.isMobile = true const mobileResult = await this.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') + log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`, 'error') const bd = detectBanReason(e) if (bd.status) { - banned.status = true; banned.reason = bd.reason.substring(0,200) + banned.status = true; banned.reason = bd.reason.substring(0, 200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null @@ -559,13 +558,13 @@ export class MicrosoftRewardsBot { const accountEnd = Date.now() const durationMs = accountEnd - accountStart const totalCollected = desktopCollected + mobileCollected - + // Sequential mode: desktop runs first, mobile starts with desktop's end points // Parallel mode: both start from same baseline, take minimum to avoid double-count - const initialTotal = this.config.parallel + const initialTotal = this.config.parallel ? Math.min(desktopInitial || Infinity, mobileInitial || Infinity) : (desktopInitial || mobileInitial || 0) - + const endTotal = initialTotal + totalCollected const summary: AccountSummary = { @@ -593,21 +592,21 @@ export class MicrosoftRewardsBot { await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green') } - await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green') + await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green') // Extra diagnostic summary when verbose if (process.env.DEBUG_REWARDS_VERBOSE === '1') { for (const summary of this.accountSummaries) { - log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`) + log('main', 'SUMMARY-DEBUG', `Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`) } } // If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open if (this.compromisedModeActive || this.globalStandby.active) { - log('main','SECURITY','Security alert active. Process kept alive for manual review. Press CTRL+C to exit when done.','warn','yellow') + log('main', 'SECURITY', 'Security alert active. Process kept alive for manual review. Press CTRL+C to exit when done.', 'warn', 'yellow') // Periodic heartbeat with cleanup on exit const standbyInterval = setInterval(() => { - log('main','SECURITY','Standby mode active: sessions kept open for review...','warn','yellow') + log('main', 'SECURITY', 'Standby mode active: sessions kept open for review...', 'warn', 'yellow') }, 5 * 60 * 1000) - + // Cleanup on process exit process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) }) process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) }) @@ -639,7 +638,7 @@ export class MicrosoftRewardsBot { DISCORD.COLOR_RED ) } catch (e) { - log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn') + log('main', 'ALERT', `Failed to send ban alert: ${e instanceof Error ? e.message : e}`, 'warn') } } @@ -658,26 +657,26 @@ export class MicrosoftRewardsBot { const now = new Date() const minsNow = now.getHours() * 60 + now.getMinutes() let nextStartMins: number | null = null - + for (const w of windows) { const [start, end] = w.split('-') if (!start || !end) continue - + const pStart = start.split(':').map(v => parseInt(v, 10)) const pEnd = end.split(':').map(v => parseInt(v, 10)) if (pStart.length !== 2 || pEnd.length !== 2) continue - + const sh = pStart[0]!, sm = pStart[1]! const eh = pEnd[0]!, em = pEnd[1]! - + // Validate hours and minutes ranges if ([sh, sm, eh, em].some(n => Number.isNaN(n))) continue if (sh < 0 || sh > 23 || eh < 0 || eh > 23) continue if (sm < 0 || sm > 59 || em < 0 || em > 59) continue - + const s = sh * 60 + sm const e = eh * 60 + em - + if (s <= e) { // Same-day window (e.g., 09:00-17:00) if (minsNow >= s && minsNow <= e) return 0 @@ -688,13 +687,13 @@ export class MicrosoftRewardsBot { nextStartMins = Math.min(nextStartMins ?? s, s) } } - + const msPerMin = 60 * 1000 if (nextStartMins != null) { const targetTodayMs = (nextStartMins - minsNow) * msPerMin return targetTodayMs > 0 ? targetTodayMs : (24 * 60 + nextStartMins - minsNow) * msPerMin } - + // No valid windows parsed -> do not block return 0 } @@ -731,7 +730,7 @@ export class MicrosoftRewardsBot { // Use SummaryReporter for modern reporting const reporter = new SummaryReporter(this.config) const summary = reporter.createSummary(accountResults, startTime, endTime) - + // Generate console output and send notifications (webhooks, ntfy, job state) await reporter.generateReport(summary) } @@ -745,16 +744,16 @@ export class MicrosoftRewardsBot { async runAutoUpdate(): Promise { const upd = this.config.update if (!upd) return 0 - + // Check if updates are enabled if (upd.enabled === false) { log('main', 'UPDATE', 'Updates disabled in config (update.enabled = false)') return 0 } - + const scriptRel = upd.scriptPath || 'setup/update/update.mjs' const scriptAbs = path.join(process.cwd(), scriptRel) - + if (!fs.existsSync(scriptAbs)) { log('main', 'UPDATE', `Update script not found: ${scriptAbs}`, 'warn') return 0 @@ -794,7 +793,7 @@ export class MicrosoftRewardsBot { try { // Idempotent: don't re-engage if already active if (this.globalStandby.active) return - + this.globalStandby = { active: true, reason } const who = email || this.currentAccountEmail || 'unknown' await this.sendGlobalSecurityStandbyAlert(who, reason) @@ -816,7 +815,7 @@ export class MicrosoftRewardsBot { DISCORD.COLOR_RED ) } catch (e) { - log('main','ALERT',`Failed to send alert: ${e instanceof Error ? e.message : e}`,'warn') + log('main', 'ALERT', `Failed to send alert: ${e instanceof Error ? e.message : e}`, 'warn') } } } @@ -861,7 +860,7 @@ async function main(): Promise { const { startDashboardServer } = await import('./dashboard/server') const { dashboardState } = await import('./dashboard/state') log('main', 'DASHBOARD', 'Starting standalone dashboard server...') - + // Load and initialize accounts try { const accounts = loadAccounts() @@ -870,7 +869,7 @@ async function main(): Promise { } catch (error) { log('main', 'DASHBOARD', 'Could not load accounts: ' + (error instanceof Error ? error.message : String(error)), 'warn') } - + startDashboardServer() return } @@ -886,15 +885,15 @@ async function main(): Promise { const { dashboardState } = await import('./dashboard/state') const port = config.dashboard.port || 3000 const host = config.dashboard.host || '127.0.0.1' - + // Override env vars with config values process.env.DASHBOARD_PORT = String(port) process.env.DASHBOARD_HOST = host - + // Initialize dashboard with accounts const accounts = loadAccounts() dashboardState.initializeAccounts(accounts.map(a => a.email)) - + const dashboardServer = new DashboardServer() dashboardServer.start() log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`) @@ -930,7 +929,7 @@ async function main(): Promise { const max = config.crashRecovery.maxRestarts ?? 2 if (crashState.restarts < max) { const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1) - log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow') + log('main', 'CRASH-RECOVERY', `Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn', 'yellow') setTimeout(() => { crashState.restarts++ bootstrap() @@ -948,20 +947,20 @@ async function main(): Promise { try { // Check /.dockerenv file if (fs.existsSync('/.dockerenv')) return true - + // Check /proc/1/cgroup if (fs.existsSync('/proc/1/cgroup')) { const content = fs.readFileSync('/proc/1/cgroup', 'utf8') if (content.includes('docker') || content.includes('/kubepods/')) return true } - + // Check environment variables - if (process.env.DOCKER === 'true' || + if (process.env.DOCKER === 'true' || process.env.CONTAINER === 'docker' || process.env.KUBERNETES_SERVICE_HOST) { return true } - + return false } catch { return false @@ -973,17 +972,17 @@ async function main(): Promise { // Check for updates BEFORE initializing and running tasks const updateMarkerPath = path.join(process.cwd(), '.update-happened') const isDocker = isDockerEnvironment() - + 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) { // Check if update marker exists (created by update.mjs when version changed) const updateHappened = fs.existsSync(updateMarkerPath) - + if (updateHappened) { // Remove marker file try { @@ -991,7 +990,7 @@ async function main(): Promise { } catch { // Ignore cleanup errors } - + if (isDocker) { // Docker mode: exit cleanly to let container restart log('main', 'UPDATE', 'Update complete - exiting for container restart', 'log', 'green') @@ -1005,7 +1004,7 @@ async function main(): Promise { delete require.cache[key] } }) - + // Recursive restart in same process log('main', 'UPDATE', 'Reloading with new version...') setTimeout(() => { @@ -1021,11 +1020,11 @@ async function main(): Promise { } 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) { - log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error') + log('main', 'MAIN-ERROR', 'Fatal during run: ' + (e instanceof Error ? e.message : e), 'error') gracefulExit(1) } } diff --git a/src/util/BrowserFactory.ts b/src/util/BrowserFactory.ts index 10ab76c..d4a5a0d 100644 --- a/src/util/BrowserFactory.ts +++ b/src/util/BrowserFactory.ts @@ -2,7 +2,7 @@ * Browser Factory Utility * Eliminates code duplication between Desktop and Mobile flows * - * Centralized browser instance creation logic + * Centralized browser instance creation and cleanup logic */ import type { BrowserContext } from 'rebrowser-playwright' @@ -22,8 +22,8 @@ import type { AccountProxy } from '../interface/Account' * const browser = await createBrowserInstance(bot, account.proxy, account.email) */ export async function createBrowserInstance( - bot: MicrosoftRewardsBot, - proxy: AccountProxy, + bot: MicrosoftRewardsBot, + proxy: AccountProxy, email: string ): Promise { const browserModule = await import('../browser/Browser') @@ -31,3 +31,30 @@ export async function createBrowserInstance( const browserInstance = new Browser(bot) return await browserInstance.createBrowser(proxy, email) } + +/** + * Safely close browser context with error handling + * IMPROVEMENT: Extracted from DesktopFlow and MobileFlow to eliminate duplication + * + * @param bot Bot instance + * @param browser Browser context to close + * @param email Account email for logging + * @param isMobile Whether this is a mobile browser context + * + * @example + * await closeBrowserSafely(bot, browser, account.email, false) + */ +export async function closeBrowserSafely( + bot: MicrosoftRewardsBot, + browser: BrowserContext, + email: string, + isMobile: boolean +): Promise { + try { + await bot.browser.func.closeBrowser(browser, email) + } catch (closeError) { + const message = closeError instanceof Error ? closeError.message : String(closeError) + const platform = isMobile ? 'mobile' : 'desktop' + bot.log(isMobile, `${platform.toUpperCase()}-FLOW`, `Failed to close ${platform} context: ${message}`, 'warn') + } +} diff --git a/src/util/Logger.ts b/src/util/Logger.ts index 986914e..50425f1 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -1,6 +1,6 @@ import axios from 'axios' import chalk from 'chalk' -import { DISCORD, TIMEOUTS } from '../constants' +import { DISCORD, LOGGER_CLEANUP } from '../constants' import { sendErrorReport } from './ErrorReportingWebhook' import { loadConfig } from './Load' import { Ntfy } from './Ntfy' @@ -26,22 +26,19 @@ type WebhookBuffer = { const webhookBuffers = new Map() // Periodic cleanup of old/idle webhook buffers to prevent memory leaks -// IMPROVED: Using centralized constants instead of magic numbers -const BUFFER_MAX_AGE_MS = TIMEOUTS.ONE_HOUR -const BUFFER_CLEANUP_INTERVAL_MS = TIMEOUTS.TEN_MINUTES - +// IMPROVED: Using centralized constants from constants.ts const cleanupInterval = setInterval(() => { const now = Date.now() - + for (const [url, buf] of webhookBuffers.entries()) { if (!buf.sending && buf.lines.length === 0) { const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0 - if (now - lastActivity > BUFFER_MAX_AGE_MS) { + if (now - lastActivity > LOGGER_CLEANUP.BUFFER_MAX_AGE_MS) { webhookBuffers.delete(url) } } } -}, BUFFER_CLEANUP_INTERVAL_MS) +}, LOGGER_CLEANUP.BUFFER_CLEANUP_INTERVAL_MS) // FIXED: Allow cleanup to be stopped with proper fallback // unref() prevents process from hanging but may not exist in all environments @@ -134,7 +131,7 @@ const COLOR_RULES: ColorRule[] = [ function determineColorFromContent(content: string): number { const lower = content.toLowerCase() - + // Check rules in priority order for (const rule of COLOR_RULES) { if (typeof rule.pattern === 'string') { @@ -143,19 +140,50 @@ function determineColorFromContent(content: string): number { if (rule.pattern.test(lower)) return rule.color } } - + return DISCORD.COLOR_GRAY } /** * Type guard to check if config has valid logging configuration + * IMPROVED: Enhanced edge case handling and null checks */ -function hasValidLogging(config: unknown): config is { logging: { excludeFunc?: string[]; webhookExcludeFunc?: string[] } } { - return typeof config === 'object' && - config !== null && - 'logging' in config && - typeof config.logging === 'object' && - config.logging !== null +function hasValidLogging(config: unknown): config is { logging: { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean; liveWebhookUrl?: string } } { + if (typeof config !== 'object' || config === null) { + return false + } + + if (!('logging' in config)) { + return false + } + + const cfg = config as Record + const logging = cfg.logging + + if (typeof logging !== 'object' || logging === null) { + return false + } + + // Validate optional fields have correct types if present + const loggingObj = logging as Record + + if ('excludeFunc' in loggingObj && !Array.isArray(loggingObj.excludeFunc)) { + return false + } + + if ('webhookExcludeFunc' in loggingObj && !Array.isArray(loggingObj.webhookExcludeFunc)) { + return false + } + + if ('redactEmails' in loggingObj && typeof loggingObj.redactEmails !== 'boolean') { + return false + } + + if ('liveWebhookUrl' in loggingObj && typeof loggingObj.liveWebhookUrl !== 'string') { + return false + } + + return true } function enqueueWebhookLog(url: string, line: string) { @@ -193,13 +221,13 @@ export function log(isMobile: boolean | 'main', title: string, message: string, const currentTime = new Date().toLocaleString() const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP' - + // Clean string for notifications (no chalk, structured) type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean } const loggingCfg: LoggingCfg = logging || {} const shouldRedact = !!loggingCfg.redactEmails const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => { - const [u, d] = m.split('@'); return `${(u||'').slice(0,2)}***@${d||''}` + const [u, d] = m.split('@'); return `${(u || '').slice(0, 2)}***@${d || ''}` }) : s const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`) @@ -210,7 +238,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, message.toLowerCase().includes('press the number'), message.toLowerCase().includes('no points to earn') ], - error: [], + error: [], warn: [ message.toLowerCase().includes('aborting'), message.toLowerCase().includes('didn\'t gain') @@ -229,11 +257,11 @@ export function log(isMobile: boolean | 'main', title: string, message: string, const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓' const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green - + // Add contextual icon based on title/message (ASCII-safe for Windows PowerShell) const titleLower = title.toLowerCase() const msgLower = message.toLowerCase() - + // ASCII-safe icons for Windows PowerShell compatibility const iconMap: Array<[RegExp, string]> = [ [/security|compromised/i, '[SECURITY]'], @@ -248,7 +276,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, [/browser/i, '[BROWSER]'], [/main/i, '[MAIN]'] ] - + let icon = '' for (const [pattern, symbol] of iconMap) { if (pattern.test(titleLower) || pattern.test(msgLower)) { @@ -256,9 +284,9 @@ export function log(isMobile: boolean | 'main', title: string, message: string, break } } - + const iconPart = icon ? icon + ' ' : '' - + const formattedStr = [ chalk.gray(`[${currentTime}]`), chalk.gray(`[${process.pid}]`), @@ -304,7 +332,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, // Automatic error reporting to community webhook (fire and forget) if (type === 'error') { const errorObj = new Error(cleanStr) - + // Send error report asynchronously without blocking Promise.resolve().then(async () => { try { @@ -318,7 +346,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, }).catch(() => { // Catch any promise rejection silently }) - + return errorObj } } \ No newline at end of file diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 14ce23d..049a4f7 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -6,7 +6,7 @@ import ms from 'ms' * @returns String representation of the error */ export function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) + return error instanceof Error ? error.message : String(error) } /** @@ -23,8 +23,8 @@ export function getErrorMessage(error: unknown): string { * formatErrorMessage('LOGIN', err, 'Failed') // 'Failed in LOGIN: Invalid credentials' */ export function formatErrorMessage(context: string, error: unknown, prefix: string = 'Error'): string { - const errorMsg = getErrorMessage(error) - return `${prefix} in ${context}: ${errorMsg}` + const errorMsg = getErrorMessage(error) + return `${prefix} in ${context}: ${errorMsg}` } /** @@ -35,21 +35,25 @@ export class Util { /** * Wait for a specified number of milliseconds - * @param ms - Milliseconds to wait (max 1 hour) - * @throws {Error} If ms is not finite or is NaN/Infinity + * @param ms - Milliseconds to wait (max 1 hour, min 0) + * @throws {Error} If ms is not finite, is NaN/Infinity, or is negative * @example await utils.wait(1000) // Wait 1 second */ wait(ms: number): Promise { const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits const MIN_WAIT_MS = 0 - - // FIXED: Simplified validation - isFinite checks both NaN and Infinity + + // FIXED: Comprehensive validation - check finite, NaN, Infinity, and negative values if (!Number.isFinite(ms)) { throw new Error(`Invalid wait time: ${ms}. Must be a finite number (not NaN or Infinity).`) } - + + if (ms < 0) { + throw new Error(`Invalid wait time: ${ms}. Cannot wait negative milliseconds.`) + } + const safeMs = Math.min(Math.max(MIN_WAIT_MS, ms), MAX_WAIT_MS) - + return new Promise((resolve) => { setTimeout(resolve, safeMs) }) @@ -66,11 +70,11 @@ export class Util { if (!Number.isFinite(minMs) || !Number.isFinite(maxMs)) { throw new Error(`Invalid wait range: min=${minMs}, max=${maxMs}. Both must be finite numbers.`) } - + if (minMs > maxMs) { throw new Error(`Invalid wait range: min (${minMs}) cannot be greater than max (${maxMs}).`) } - + const delta = this.randomNumber(minMs, maxMs) return this.wait(delta) } @@ -115,11 +119,11 @@ export class Util { if (!Number.isFinite(min) || !Number.isFinite(max)) { throw new Error(`Invalid range: min=${min}, max=${max}. Both must be finite numbers.`) } - + if (min > max) { throw new Error(`Invalid range: min (${min}) cannot be greater than max (${max}).`) } - + return Math.floor(Math.random() * (max - min + 1)) + min } @@ -136,19 +140,19 @@ export class Util { if (!Array.isArray(arr)) { throw new Error('Invalid input: arr must be an array.') } - + if (arr.length === 0) { return [] } - + if (!Number.isFinite(numChunks) || numChunks <= 0) { throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive finite number.`) } - + if (!Number.isInteger(numChunks)) { throw new Error(`Invalid numChunks: ${numChunks}. Must be an integer.`) } - + const safeNumChunks = Math.max(1, Math.floor(numChunks)) const chunkSize = Math.ceil(arr.length / safeNumChunks) const chunks: T[][] = [] @@ -174,7 +178,7 @@ export class Util { if (typeof input !== 'string' && typeof input !== 'number') { throw new Error('Invalid input type. Expected string or number.') } - + const milisec = ms(input.toString()) if (!milisec || !Number.isFinite(milisec)) { throw new Error('The string provided cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"') @@ -214,4 +218,25 @@ export function formatDetailedError(label: string, error: unknown, includeStack: return `${label}:${baseMessage} :: ${stackLines}` } return `${label}:${baseMessage}` +} + +/** + * Validate and normalize recovery email + * IMPROVEMENT: Extracted to eliminate duplication and provide consistent validation + * + * @param recoveryEmail - Raw recovery email value from account configuration + * @returns Normalized recovery email string or undefined if invalid + * + * @example + * normalizeRecoveryEmail(' test@example.com ') // 'test@example.com' + * normalizeRecoveryEmail('') // undefined + * normalizeRecoveryEmail(undefined) // undefined + */ +export function normalizeRecoveryEmail(recoveryEmail: unknown): string | undefined { + if (typeof recoveryEmail !== 'string') { + return undefined + } + + const trimmed = recoveryEmail.trim() + return trimmed === '' ? undefined : trimmed } \ No newline at end of file