mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
feat: Enhance Microsoft Rewards Bot with recovery email normalization and improved logging
- Added `normalizeRecoveryEmail` utility function for consistent recovery email validation. - Improved logging functionality in `Logger.ts` with enhanced edge case handling and null checks. - Centralized browser cleanup logic in `BrowserFactory.ts` to eliminate duplication. - Refactored error handling and message formatting in `Utils.ts` for better clarity and consistency. - Updated various log messages for improved readability and consistency across the codebase. - Implemented periodic cleanup of webhook buffers in `Logger.ts` using centralized constants.
This commit is contained in:
@@ -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
|
||||
@@ -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<DesktopFlowResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MobileFlowResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
223
src/index.ts
223
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<boolean> {
|
||||
// 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<void>(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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BrowserContext> {
|
||||
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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, WebhookBuffer>()
|
||||
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void>((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
|
||||
}
|
||||
Reference in New Issue
Block a user