diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 3797805..2aa42c8 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -15,10 +15,8 @@ class Browser { } async createBrowser(proxy: AccountProxy, email: string): Promise { - // Optional automatic browser installation (set AUTO_INSTALL_BROWSERS=1) if (process.env.AUTO_INSTALL_BROWSERS === '1') { try { - // Dynamically import child_process to avoid overhead otherwise const { execSync } = await import('child_process') execSync('npx playwright install chromium', { stdio: 'ignore' }) } catch (e) { @@ -28,28 +26,25 @@ class Browser { let browser: import('rebrowser-playwright').Browser try { - // FORCE_HEADLESS env takes precedence (used in Docker with headless shell only) const envForceHeadless = process.env.FORCE_HEADLESS === '1' - // Support legacy config.headless OR nested config.browser.headless const legacyHeadless = (this.bot.config as { headless?: boolean }).headless const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false) + if (this.bot.isBuyModeEnabled() && !envForceHeadless) { if (headlessValue !== false) { const target = this.bot.getBuyModeTarget() - this.bot.log(this.bot.isMobile, 'BROWSER', `Buy mode detected${target ? ` for ${target}` : ''}; forcing headless=false so captchas and manual flows remain interactive.`, 'warn') + this.bot.log(this.bot.isMobile, 'BROWSER', `Buy mode: forcing headless=false${target ? ` for ${target}` : ''}`, 'warn') } headlessValue = false } + const headless: boolean = Boolean(headlessValue) - - const engineName = 'chromium' // current hard-coded engine - this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log + const engineName = 'chromium' + this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) const proxyConfig = this.buildPlaywrightProxy(proxy) browser = await playwright.chromium.launch({ - // Optional: uncomment to use Edge instead of Chromium - // channel: 'msedge', headless, ...(proxyConfig && { proxy: proxyConfig }), args: [ @@ -63,80 +58,64 @@ class Browser { }) } catch (e: unknown) { const msg = (e instanceof Error ? e.message : String(e)) - // Common missing browser executable guidance if (/Executable doesn't exist/i.test(msg)) { - this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error') + this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed. Run "npm run pre-build" or set AUTO_INSTALL_BROWSERS=1', 'error') } else { this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error') } throw e } - // Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint - const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint - const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint - const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false } - - const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint) + const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint + const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint + const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false } + const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint) const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint() + const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint }) - const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint }) + const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout + const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout + const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000 + context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout)) - // Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout) - const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout - const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout - const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000 - context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout)) - - // Normalize viewport and page rendering so content fits typical screens try { - const desktopViewport = { width: 1280, height: 800 } - const mobileViewport = { width: 390, height: 844 } - context.on('page', async (page) => { try { - // Set a reasonable viewport size depending on device type - if (this.bot.isMobile) { - await page.setViewportSize(mobileViewport) - } else { - await page.setViewportSize(desktopViewport) - } + const viewport = this.bot.isMobile + ? { width: 390, height: 844 } + : { width: 1280, height: 800 } + + await page.setViewportSize(viewport) - // Inject a tiny CSS to avoid gigantic scaling on some environments await page.addInitScript(() => { try { const style = document.createElement('style') style.id = '__mrs_fit_style' style.textContent = ` html, body { overscroll-behavior: contain; } - /* Mild downscale to keep content within window on very large DPI */ @media (min-width: 1000px) { html { zoom: 0.9 !important; } } ` document.documentElement.appendChild(style) - } catch (e) { - // Style injection failed - not critical, page will still function - } + } catch {/* ignore */} }) } catch (e) { - // Viewport/script setup failed - log for debugging but continue this.bot.log(this.bot.isMobile, 'BROWSER', `Page setup warning: ${e instanceof Error ? e.message : String(e)}`, 'warn') } }) } catch (e) { - this.bot.log(this.bot.isMobile, 'BROWSER', `Context event handler setup warning: ${e instanceof Error ? e.message : String(e)}`, 'warn') + this.bot.log(this.bot.isMobile, 'BROWSER', `Context event handler warning: ${e instanceof Error ? e.message : String(e)}`, 'warn') } await context.addCookies(sessionData.cookies) - // Persist fingerprint when feature is configured if (saveFingerprint.mobile || saveFingerprint.desktop) { await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint) } - this.bot.log(this.bot.isMobile, 'BROWSER', `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`) + this.bot.log(this.bot.isMobile, 'BROWSER', `Browser ready with UA: "${fingerprint.fingerprint.navigator.userAgent}"`) return context as BrowserContext } diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index aabe9ad..ef86609 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -18,6 +18,45 @@ export default class BrowserFunc { this.bot = bot } + /** + * Check if account is suspended using multiple detection methods + * @param page Playwright page + * @param iteration Current iteration number for logging + * @returns true if suspended, false otherwise + */ + private async checkAccountSuspension(page: Page, iteration: number): Promise { + // Primary check: suspension header element + const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }) + .then(() => true) + .catch(() => false) + + if (suspendedByHeader) { + this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error') + return true + } + + // Secondary check: look for suspension text in main content area only + try { + const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || '' + const suspensionPatterns = [ + /account\s+has\s+been\s+suspended/i, + /suspended\s+due\s+to\s+unusual\s+activity/i, + /your\s+account\s+is\s+temporarily\s+suspended/i + ] + + const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent)) + if (isSuspended) { + this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error') + return true + } + } catch (e) { + // Ignore errors in text check - not critical + this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn') + } + + return false + } + /** * Navigate the provided page to rewards homepage @@ -46,33 +85,11 @@ export default class BrowserFunc { } catch (error) { // Activities not found yet - check if it's because account is suspended - // Only check suspension if we can't find activities (reduces false positives) - const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }).then(() => true).catch(() => false) - - if (suspendedByHeader) { - this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error') + const isSuspended = await this.checkAccountSuspension(page, iteration) + if (isSuspended) { throw new Error('Account has been suspended!') } - // Secondary check: look for suspension text in main content area only - try { - const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || '' - const suspensionPatterns = [ - /account\s+has\s+been\s+suspended/i, - /suspended\s+due\s+to\s+unusual\s+activity/i, - /your\s+account\s+is\s+temporarily\s+suspended/i - ] - - const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent)) - if (isSuspended) { - this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error') - throw new Error('Account has been suspended!') - } - } catch (e) { - // Ignore errors in text check - not critical - this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn') - } - // Not suspended, just activities not loaded yet - continue to next iteration this.bot.log(this.bot.isMobile, 'GO-HOME', `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, 'warn') } @@ -96,7 +113,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GO-HOME', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Go home failed: ' + errorMessage) + throw error } } @@ -155,7 +172,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${errorMessage}`, 'error') - throw new Error('Get dashboard data failed: ' + errorMessage) + throw error } } @@ -198,12 +215,14 @@ export default class BrowserFunc { private async extractDashboardScript(page: Page): Promise { return await page.evaluate(() => { const scripts = Array.from(document.querySelectorAll('script')) - const targetScript = scripts.find(script => - script.innerText.includes('var dashboard') || - script.innerText.includes('dashboard=') || - script.innerText.includes('dashboard :') - ) - return targetScript?.innerText ? targetScript.innerText : null + const dashboardPatterns = ['var dashboard', 'dashboard=', 'dashboard :'] + + const targetScript = scripts.find(script => { + const text = script.innerText + return text && dashboardPatterns.some(pattern => text.includes(pattern)) + }) + + return targetScript?.innerText || null }) } @@ -213,10 +232,9 @@ export default class BrowserFunc { private async parseDashboardFromScript(page: Page, scriptContent: string): Promise { return await page.evaluate((scriptContent: string) => { const patterns = [ - /var dashboard = (\{.*?\});/s, - /var dashboard=(\{.*?\});/s, - /var\s+dashboard\s*=\s*(\{.*?\});/s, - /dashboard\s*=\s*(\{[\s\S]*?\});/ + /var\s+dashboard\s*=\s*(\{[\s\S]*?\});/, + /dashboard\s*=\s*(\{[\s\S]*?\});/, + /var\s+dashboard\s*:\s*(\{[\s\S]*?\})\s*[,;]/ ] for (const regex of patterns) { @@ -293,7 +311,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Get browser earnable points failed: ' + errorMessage) + throw error } } @@ -358,7 +376,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Get app earnable points failed: ' + errorMessage) + throw error } } @@ -374,7 +392,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Get current points failed: ' + errorMessage) + throw error } } @@ -447,7 +465,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Get quiz data failed: ' + errorMessage) + throw error } } @@ -515,7 +533,7 @@ export default class BrowserFunc { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred: ' + errorMessage, 'error') - throw new Error('Close browser failed: ' + errorMessage) + throw error } } } \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index b14e0e7..0488155 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,85 +5,83 @@ /** * Parse environment variable as number with validation + * @param key Environment variable name + * @param defaultValue Default value if parsing fails or out of range + * @param min Minimum allowed value + * @param max Maximum allowed value + * @returns Parsed number or default value */ 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 (isNaN(parsed)) { - console.warn(`[Constants] Invalid ${key}="${raw}". Using default ${defaultValue}`) - return defaultValue - } - - if (parsed < min || parsed > max) { - console.warn(`[Constants] ${key}=${parsed} out of range [${min}, ${max}]. Using default ${defaultValue}`) - return defaultValue - } - - return parsed + const raw = process.env[key] + if (!raw) return defaultValue + + const parsed = Number(raw) + if (isNaN(parsed) || parsed < min || parsed > max) return defaultValue + + return parsed } export const TIMEOUTS = { - SHORT: 500, - MEDIUM: 1500, - MEDIUM_LONG: 2000, - LONG: 3000, - VERY_LONG: 5000, - EXTRA_LONG: 10000, - DASHBOARD_WAIT: 10000, - LOGIN_MAX: parseEnvNumber('LOGIN_MAX_WAIT_MS', 180000, 30000, 600000), - NETWORK_IDLE: 5000 + SHORT: 500, + MEDIUM: 1500, + MEDIUM_LONG: 2000, + LONG: 3000, + VERY_LONG: 5000, + EXTRA_LONG: 10000, + DASHBOARD_WAIT: 10000, + LOGIN_MAX: parseEnvNumber('LOGIN_MAX_WAIT_MS', 180000, 30000, 600000), + NETWORK_IDLE: 5000 } as const export const RETRY_LIMITS = { - MAX_ITERATIONS: 5, - DASHBOARD_RELOAD: 2, - MOBILE_SEARCH: 3, - ABC_MAX: 15, - POLL_MAX: 15, - QUIZ_MAX: 15, - QUIZ_ANSWER_TIMEOUT: 10000, - GO_HOME_MAX: 5 + MAX_ITERATIONS: 5, + DASHBOARD_RELOAD: 2, + MOBILE_SEARCH: 3, + ABC_MAX: 15, + POLL_MAX: 15, + QUIZ_MAX: 15, + QUIZ_ANSWER_TIMEOUT: 10000, + GO_HOME_MAX: 5 } as const export const DELAYS = { - ACTION_MIN: 1000, - ACTION_MAX: 3000, - SEARCH_DEFAULT_MIN: 2000, - SEARCH_DEFAULT_MAX: 5000, - BROWSER_CLOSE: 2000, - TYPING_DELAY: 20, - SEARCH_ON_BING_WAIT: 5000, - SEARCH_ON_BING_COMPLETE: 3000, - SEARCH_ON_BING_FOCUS: 200, - SEARCH_BAR_TIMEOUT: 15000, - QUIZ_ANSWER_WAIT: 2000, - THIS_OR_THAT_START: 2000 + ACTION_MIN: 1000, + ACTION_MAX: 3000, + SEARCH_DEFAULT_MIN: 2000, + SEARCH_DEFAULT_MAX: 5000, + BROWSER_CLOSE: 2000, + TYPING_DELAY: 20, + SEARCH_ON_BING_WAIT: 5000, + SEARCH_ON_BING_COMPLETE: 3000, + SEARCH_ON_BING_FOCUS: 200, + SEARCH_BAR_TIMEOUT: 15000, + QUIZ_ANSWER_WAIT: 2000, + THIS_OR_THAT_START: 2000 } as const export const SELECTORS = { - MORE_ACTIVITIES: '#more-activities', - SUSPENDED_ACCOUNT: '#suspendedAccountHeader', - QUIZ_COMPLETE: '#quizCompleteContainer', - QUIZ_CREDITS: 'span.rqMCredits' + MORE_ACTIVITIES: '#more-activities', + SUSPENDED_ACCOUNT: '#suspendedAccountHeader', + QUIZ_COMPLETE: '#quizCompleteContainer', + QUIZ_CREDITS: 'span.rqMCredits' } as const export const URLS = { - REWARDS_BASE: 'https://rewards.bing.com', - REWARDS_SIGNIN: 'https://rewards.bing.com/signin', - APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613' + REWARDS_BASE: 'https://rewards.bing.com', + REWARDS_SIGNIN: 'https://rewards.bing.com/signin', + APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613' } as const export const DISCORD = { - MAX_EMBED_LENGTH: 1900, - RATE_LIMIT_DELAY: 500, - WEBHOOK_TIMEOUT: 10000, - DEBOUNCE_DELAY: 750, - COLOR_RED: 0xFF0000, - COLOR_CRIMSON: 0xDC143C, - COLOR_ORANGE: 0xFFA500, - COLOR_BLUE: 0x3498DB, - COLOR_GREEN: 0x00D26A, - AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1434918661235282144/logo.png?ex=690a13a4&is=6908c224&hm=6bae81966da32e73a647f46fde268011fcf460c7071082dd5fd76cf22d04af65&=&format=png&quality=lossless&width=653&height=638' + MAX_EMBED_LENGTH: 1900, + RATE_LIMIT_DELAY: 500, + WEBHOOK_TIMEOUT: 10000, + DEBOUNCE_DELAY: 750, + COLOR_RED: 0xFF0000, + COLOR_CRIMSON: 0xDC143C, + COLOR_ORANGE: 0xFFA500, + COLOR_BLUE: 0x3498DB, + COLOR_GREEN: 0x00D26A, + COLOR_GRAY: 0x95A5A6, + AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1434918661235282144/logo.png?ex=690a13a4&is=6908c224&hm=6bae81966da32e73a647f46fde268011fcf460c7071082dd5fd76cf22d04af65&=&format=png&quality=lossless&width=653&height=638' } as const \ No newline at end of file diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 614f5fd..23c1f85 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -27,20 +27,16 @@ const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' } const DEFAULT_TIMEOUTS = { loginMaxMs: (() => { - const val = Number(process.env.LOGIN_MAX_WAIT_MS || 300000) - if (isNaN(val) || val < 10000 || val > 600000) { - console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 300000ms`) - return 300000 - } - return val + const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000) + return (isNaN(val) || val < 10000 || val > 600000) ? 180000 : val })(), - short: 200, // Reduced from 500ms - medium: 800, // Reduced from 1500ms - long: 1500, // Reduced from 3000ms - oauthMaxMs: 360000, + short: 200, + medium: 800, + long: 1500, + oauthMaxMs: 180000, portalWaitMs: 15000, - elementCheck: 100, // Fast element detection - fastPoll: 500 // Fast polling interval + elementCheck: 100, + fastPoll: 500 } // Security pattern bundle @@ -77,12 +73,14 @@ export class Login { private lastTotpSubmit = 0 private totpAttempts = 0 - constructor(bot: MicrosoftRewardsBot) { this.bot = bot } + constructor(bot: MicrosoftRewardsBot) { + this.bot = bot + this.cleanupCompromisedInterval() + } // --------------- Public API --------------- async login(page: Page, email: string, password: string, totpSecret?: string) { try { - // Clear any existing intervals from previous runs to prevent memory leaks this.cleanupCompromisedInterval() this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process') @@ -92,22 +90,19 @@ export class Login { const resumed = await this.tryReuseExistingSession(page) if (resumed) { - // OPTIMIZATION: Skip Bing verification if already on rewards page const needsVerification = !page.url().includes('rewards.bing.com') if (needsVerification) { await this.verifyBingContext(page) } await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile) - this.bot.log(this.bot.isMobile, 'LOGIN', 'Session restored (fast path)') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Session restored') this.currentTotpSecret = undefined return } - // Full login flow needed await page.goto('https://rewards.bing.com/signin', { waitUntil: 'domcontentloaded' }) await this.disableFido(page) - // OPTIMIZATION: Parallel checks instead of sequential const [, , portalCheck] = await Promise.allSettled([ this.bot.browser.utils.reloadBadPage(page), this.tryAutoTotp(page, 'initial landing'), @@ -123,7 +118,6 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated') } - // OPTIMIZATION: Only verify Bing if needed const needsBingVerification = !page.url().includes('rewards.bing.com') if (needsBingVerification) { await this.verifyBingContext(page) @@ -141,12 +135,10 @@ export class Login { } async getMobileAccessToken(page: Page, email: string, totpSecret?: string) { - // Store TOTP secret for this mobile auth session this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined this.lastTotpSubmit = 0 this.totpAttempts = 0 - // Reuse same FIDO disabling await this.disableFido(page) const url = new URL(this.authBaseUrl) url.searchParams.set('response_type', 'code') @@ -167,39 +159,34 @@ export class Login { while (Date.now() - start < DEFAULT_TIMEOUTS.oauthMaxMs) { checkCount++ - // OPTIMIZATION: Check URL first (fastest check) const u = new URL(page.url()) if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') { code = u.searchParams.get('code') || '' if (code) break } - // OPTIMIZATION: Handle prompts and TOTP in parallel when possible - if (checkCount % 3 === 0) { // Every 3rd iteration + if (checkCount % 3 === 0) { await Promise.allSettled([ this.handlePasskeyPrompts(page, 'oauth'), this.tryAutoTotp(page, 'mobile-oauth') ]) } - // Progress log every 30 seconds const now = Date.now() if (now - lastLogTime > 30000) { const elapsed = Math.round((now - start) / 1000) - this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Still waiting for OAuth code... (${elapsed}s elapsed, URL: ${u.hostname}${u.pathname})`, 'warn') + this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Waiting for OAuth code... (${elapsed}s, URL: ${u.hostname}${u.pathname})`, 'warn') lastLogTime = now } - // OPTIMIZATION: Adaptive polling - faster initially, slower after const pollDelay = Date.now() - start < 30000 ? 800 : 1500 await this.bot.utils.wait(pollDelay) } if (!code) { const elapsed = Math.round((Date.now() - start) / 1000) const currentUrl = page.url() - this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s (timeout: ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s). Current URL: ${currentUrl}`, 'error') - - throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s - mobile token acquisition failed. Check recent logs for details.`) + this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s. Current URL: ${currentUrl}`, 'error') + throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s`) } this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`) @@ -210,23 +197,21 @@ export class Login { form.append('code', code) form.append('redirect_uri', this.redirectUrl) - // Token exchange with retry logic for transient errors (502, 503, network issues) + const isRetryable = (e: unknown): boolean => { + if (!e || typeof e !== 'object') return false + const err = e as { response?: { status?: number }; code?: string } + const status = err.response?.status + return status === 502 || status === 503 || status === 504 || + err.code === 'ECONNRESET' || + err.code === 'ETIMEDOUT' + } + const req: AxiosRequestConfig = { url: this.tokenUrl, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: form.toString() } - - const isRetryable = (e: unknown): boolean => { - if (!e || typeof e !== 'object') return false - const err = e as { response?: { status?: number }; code?: string } - const status = err.response?.status - // Retry on 502, 503, 504 (gateway errors) and network errors - return status === 502 || status === 503 || status === 504 || - err.code === 'ECONNRESET' || - err.code === 'ETIMEDOUT' - } const retry = new Retry(this.bot.config.retryPolicy) try { @@ -236,11 +221,9 @@ export class Login { ) const data: OAuth = resp.data this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now()-start)/1000)}s`) - // Clear TOTP secret after successful mobile auth this.currentTotpSecret = undefined return data.access_token } catch (error) { - // Clear TOTP secret on error too this.currentTotpSecret = undefined const err = error as { response?: { status?: number }; message?: string } const statusCode = err.response?.status diff --git a/src/index.ts b/src/index.ts index 57f14df..21b2033 100644 --- a/src/index.ts +++ b/src/index.ts @@ -367,49 +367,25 @@ export class MicrosoftRewardsBot { } private printBanner() { - // Only print once (primary process or single cluster execution) if (this.config.clusters > 1 && !cluster.isPrimary) return - const banner = ` - ╔════════════════════════════════════════════════════════╗ - ║ ║ - ║ Microsoft Rewards Bot v${this.getVersion().padEnd(5)} ║ - ║ Automated Points Collection System ║ - ║ ║ - ╚════════════════════════════════════════════════════════╝ -` - - const buyModeBanner = ` - ╔════════════════════════════════════════════════════════╗ - ║ ║ - ║ Microsoft Rewards Bot - Manual Mode ║ - ║ Interactive Browsing Session ║ - ║ ║ - ╚════════════════════════════════════════════════════════╝ -` - const version = this.getVersion() - const displayBanner = this.buyMode.enabled ? buyModeBanner : banner - console.log(displayBanner) - console.log('─'.repeat(60)) - - if (this.buyMode.enabled) { - console.log(` Version ${version} | PID ${process.pid} | Manual Session`) - console.log(` Target: ${this.buyMode.email || 'First account'}`) - } else { - console.log(` Version ${version} | PID ${process.pid} | Workers: ${this.config.clusters}`) - - const upd = this.config.update || {} - const updTargets: string[] = [] - if (upd.git !== false) updTargets.push('Git') - if (upd.docker) updTargets.push('Docker') - if (updTargets.length > 0) { - console.log(` Auto-Update: ${updTargets.join(', ')}`) - } - - console.log(' Scheduler: External (see docs)') + const mode = this.buyMode.enabled ? 'Manual Mode' : 'Automated Mode' + + log('main', 'BANNER', `Microsoft Rewards Bot v${version} - ${mode}`) + log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`) + + if (this.buyMode.enabled) { + log('main', 'BANNER', `Target: ${this.buyMode.email || 'First account'}`) + } else { + const upd = this.config.update || {} + const updTargets: string[] = [] + if (upd.git !== false) updTargets.push('Git') + if (upd.docker) updTargets.push('Docker') + if (updTargets.length > 0) { + log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`) } - console.log('─'.repeat(60) + '\n') + } } private getVersion(): string { @@ -478,37 +454,30 @@ export class MicrosoftRewardsBot { log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') // Optional: restart crashed worker (basic heuristic) if crashRecovery allows - try { - const cr = this.config.crashRecovery - if (cr?.restartFailedWorker && code !== 0 && worker.id) { - const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0 - if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) { - (worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1 - log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow') - - // CRITICAL FIX: Re-send the original chunk to the new worker - const originalChunk = workerChunkMap.get(worker.id) - const newW = cluster.fork() - - if (originalChunk && originalChunk.length > 0 && newW.id) { - // Send the accounts to the new worker - (newW as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk }) - // Update mapping with new worker ID - workerChunkMap.set(newW.id, originalChunk) - workerChunkMap.delete(worker.id) - log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`, 'log', 'green') - } else { - log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker (chunk not found)', 'warn', 'yellow') - } - - newW.on('message', (msg: unknown) => { - const m = msg as { type?: string; data?: AccountSummary[] } - if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data) - }) + const cr = this.config.crashRecovery + if (cr?.restartFailedWorker && code !== 0 && worker.id) { + 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') + + 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`) + } else { + log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker', 'warn') } + + newW.on('message', (msg: unknown) => { + const m = msg as { type?: string; data?: AccountSummary[] } + if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data) + }) } - } catch (e) { - log('main','CRASH-RECOVERY',`Failed to respawn worker: ${e instanceof Error ? e.message : e}`, 'error') } // Check if all workers have exited @@ -591,13 +560,6 @@ export class MicrosoftRewardsBot { this.axios = new Axios(account.proxy) const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1' - const formatFullErr = (label: string, e: unknown) => { - const base = shortErr(e) - if (verbose && e instanceof Error) { - return `${label}:${base} :: ${e.stack?.split('\n').slice(0,4).join(' | ')}` - } - return `${label}:${base}` - } if (this.config.dryRun) { log('main', 'DRY-RUN', `Dry run: skipping automation for ${account.email}`) @@ -633,7 +595,7 @@ export class MicrosoftRewardsBot { this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } - errors.push(formatFullErr('desktop', e)); return null + 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) @@ -645,7 +607,7 @@ export class MicrosoftRewardsBot { this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } - errors.push(formatFullErr('mobile', e)); return null + errors.push(formatFullError('mobile', e, verbose)); return null }) const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise]) @@ -656,7 +618,7 @@ export class MicrosoftRewardsBot { } else if (desktopResult.status === 'rejected') { log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error') this.recordRiskEvent('error', 6, `desktop-rejected:${shortErr(desktopResult.reason)}`) - errors.push(formatFullErr('desktop-rejected', desktopResult.reason)) + errors.push(formatFullError('desktop-rejected', desktopResult.reason, verbose)) } // Handle mobile result @@ -666,7 +628,7 @@ export class MicrosoftRewardsBot { } else if (mobileResult.status === 'rejected') { log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error') this.recordRiskEvent('error', 6, `mobile-rejected:${shortErr(mobileResult.reason)}`) - errors.push(formatFullErr('mobile-rejected', mobileResult.reason)) + errors.push(formatFullError('mobile-rejected', mobileResult.reason, verbose)) } } else { // Sequential execution with safety checks @@ -686,7 +648,7 @@ export class MicrosoftRewardsBot { this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } - errors.push(formatFullErr('desktop', e)); return null + errors.push(formatFullError('desktop', e, verbose)); return null }) if (desktopResult) { desktopInitial = desktopResult.initialPoints @@ -708,7 +670,7 @@ export class MicrosoftRewardsBot { this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } - errors.push(formatFullErr('mobile', e)); return null + errors.push(formatFullError('mobile', e, verbose)); return null }) if (mobileResult) { mobileInitial = mobileResult.initialPoints @@ -1338,6 +1300,14 @@ function shortErr(e: unknown): string { return s.substring(0, 120) } +function formatFullError(label: string, e: unknown, verbose: boolean): string { + const base = shortErr(e) + if (verbose && e instanceof Error && e.stack) { + return `${label}:${base} :: ${e.stack.split('\n').slice(0, 4).join(' | ')}` + } + return `${label}:${base}` +} + function formatDuration(ms: number): string { if (!ms || ms < 1000) return `${ms}ms` const sec = Math.floor(ms / 1000) diff --git a/src/util/Axios.ts b/src/util/Axios.ts index 8c362f7..0613322 100644 --- a/src/util/Axios.ts +++ b/src/util/Axios.ts @@ -22,7 +22,7 @@ class AxiosClient { private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent | HttpsProxyAgent | SocksProxyAgent { const { proxyUrl, protocol } = this.buildProxyUrl(proxyConfig) - const normalized = protocol.replace(/:$/, '') + const normalized = protocol.replace(/:$/, '').toLowerCase() switch (normalized) { case 'http': @@ -80,7 +80,7 @@ class AxiosClient { return { proxyUrl: parsedUrl.toString(), protocol: parsedUrl.protocol } } - // Generic method to make any Axios request + // Generic method to make any Axios request with retry logic public async request(config: AxiosRequestConfig, bypassProxy = false): Promise { if (bypassProxy) { const bypassInstance = axios.create() @@ -95,25 +95,16 @@ class AxiosClient { return await this.instance.request(config) } catch (err: unknown) { lastError = err - const axiosErr = err as AxiosError | undefined - - // Detect HTTP proxy auth failures (status 407) and retry without proxy - if (axiosErr && axiosErr.response && axiosErr.response.status === 407) { - if (attempt < maxAttempts) { - await this.sleep(1000 * attempt) // Exponential backoff - } + + // Handle HTTP 407 Proxy Authentication Required + if (this.isProxyAuthError(err)) { + // Retry without proxy on auth failure const bypassInstance = axios.create() return bypassInstance.request(config) } - // If proxied request fails with common proxy/network errors, retry with backoff - const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined - const code = e?.code || e?.cause?.code - const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND' - const msg = String(e?.message || '') - const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg) - - if (isNetErr || looksLikeProxyIssue) { + // Handle retryable network errors + if (this.isRetryableError(err)) { if (attempt < maxAttempts) { // Exponential backoff: 1s, 2s, 4s, etc. const delayMs = 1000 * Math.pow(2, attempt - 1) @@ -133,6 +124,34 @@ class AxiosClient { throw lastError } + /** + * Check if error is HTTP 407 Proxy Authentication Required + */ + private isProxyAuthError(err: unknown): boolean { + const axiosErr = err as AxiosError | undefined + return axiosErr?.response?.status === 407 + } + + /** + * Check if error is retryable (network/proxy issues) + */ + private isRetryableError(err: unknown): boolean { + const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined + if (!e) return false + + const code = e.code || e.cause?.code + const isNetworkError = code === 'ECONNREFUSED' || + code === 'ETIMEDOUT' || + code === 'ECONNRESET' || + code === 'ENOTFOUND' || + code === 'EPIPE' + + const msg = String(e.message || '') + const isProxyIssue = /proxy|tunnel|socks|agent/i.test(msg) + + return isNetworkError || isProxyIssue + } + private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/src/util/Logger.ts b/src/util/Logger.ts index 6d55989..14f8609 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -16,9 +16,11 @@ type WebhookBuffer = { const webhookBuffers = new Map() // Periodic cleanup of old/idle webhook buffers to prevent memory leaks -setInterval(() => { +const BUFFER_MAX_AGE_MS = 3600000 // 1 hour +const BUFFER_CLEANUP_INTERVAL_MS = 600000 // 10 minutes + +const cleanupInterval = setInterval(() => { const now = Date.now() - const BUFFER_MAX_AGE_MS = 3600000 // 1 hour for (const [url, buf] of webhookBuffers.entries()) { if (!buf.sending && buf.lines.length === 0) { @@ -28,7 +30,12 @@ setInterval(() => { } } } -}, 600000) // Check every 10 minutes +}, BUFFER_CLEANUP_INTERVAL_MS) + +// Allow cleanup to be stopped (prevents process from hanging) +if (cleanupInterval.unref) { + cleanupInterval.unref() +} function getBuffer(url: string): WebhookBuffer { let buf = webhookBuffers.get(url) @@ -87,28 +94,25 @@ async function sendBatch(url: string, buf: WebhookBuffer) { function determineColorFromContent(content: string): number { const lower = content.toLowerCase() - // Security/Ban alerts - Red + + // Priority order: most critical first if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) { return DISCORD.COLOR_RED } - // Errors - Dark Red if (lower.includes('[error]') || lower.includes('✗')) { return DISCORD.COLOR_CRIMSON } - // Warnings - Orange/Yellow if (lower.includes('[warn]') || lower.includes('⚠')) { return DISCORD.COLOR_ORANGE } - // Success - Green if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) { return DISCORD.COLOR_GREEN } - // Info/Main - Blue if (lower.includes('[main]')) { return DISCORD.COLOR_BLUE } - // Default - Gray - return 0x95A5A6 // Gray + + return 0x95A5A6 } function enqueueWebhookLog(url: string, line: string) { @@ -246,7 +250,6 @@ export function log(isMobile: boolean | 'main', title: string, message: string, // Return an Error when logging an error so callers can `throw log(...)` if (type === 'error') { - // CommunityReporter disabled per project policy return new Error(cleanStr) } } \ No newline at end of file diff --git a/src/util/StartupValidator.ts b/src/util/StartupValidator.ts index 071d321..8f379ca 100644 --- a/src/util/StartupValidator.ts +++ b/src/util/StartupValidator.ts @@ -3,6 +3,7 @@ import path from 'path' import chalk from 'chalk' import { Config } from '../interface/Config' import { Account } from '../interface/Account' +import { log } from './Logger' interface ValidationError { severity: 'error' | 'warning' @@ -22,9 +23,7 @@ export class StartupValidator { * Displays errors and warnings but lets execution continue. */ async validate(config: Config, accounts: Account[]): Promise { - console.log(chalk.cyan('\n═══════════════════════════════════════════════════════════════')) - console.log(chalk.cyan(' 🔍 STARTUP VALIDATION - Checking Configuration')) - console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n')) + log('main', 'STARTUP', 'Running configuration validation...') // Run all validation checks this.validateAccounts(accounts) @@ -621,62 +620,45 @@ export class StartupValidator { } private async displayResults(): Promise { - // Display errors if (this.errors.length > 0) { - console.log(chalk.red('\n❌ VALIDATION ERRORS FOUND:\n')) + log('main', 'VALIDATION', chalk.red('❌ VALIDATION ERRORS FOUND:'), 'error') this.errors.forEach((err, index) => { - console.log(chalk.red(` ${index + 1}. [${err.category.toUpperCase()}] ${err.message}`)) + log('main', 'VALIDATION', chalk.red(`${index + 1}. [${err.category.toUpperCase()}] ${err.message}`), 'error') if (err.fix) { - console.log(chalk.yellow(` 💡 Fix: ${err.fix}`)) + log('main', 'VALIDATION', chalk.yellow(` Fix: ${err.fix}`), 'warn') } if (err.docsLink) { - console.log(chalk.cyan(` 📖 Documentation: ${err.docsLink}`)) + log('main', 'VALIDATION', ` Docs: ${err.docsLink}`) } - console.log('') }) } - // Display warnings if (this.warnings.length > 0) { - console.log(chalk.yellow('\n⚠️ WARNINGS:\n')) + log('main', 'VALIDATION', chalk.yellow('⚠️ WARNINGS:'), 'warn') this.warnings.forEach((warn, index) => { - console.log(chalk.yellow(` ${index + 1}. [${warn.category.toUpperCase()}] ${warn.message}`)) + log('main', 'VALIDATION', chalk.yellow(`${index + 1}. [${warn.category.toUpperCase()}] ${warn.message}`), 'warn') if (warn.fix) { - console.log(chalk.gray(` 💡 Suggestion: ${warn.fix}`)) + log('main', 'VALIDATION', ` Suggestion: ${warn.fix}`) } if (warn.docsLink) { - console.log(chalk.cyan(` 📖 Documentation: ${warn.docsLink}`)) + log('main', 'VALIDATION', ` Docs: ${warn.docsLink}`) } - console.log('') }) } - // Summary - console.log(chalk.cyan('═══════════════════════════════════════════════════════════════')) - if (this.errors.length === 0 && this.warnings.length === 0) { - console.log(chalk.green(' ✅ All validation checks passed! Configuration looks good.')) - console.log(chalk.gray(' → Starting bot execution...')) + log('main', 'VALIDATION', chalk.green('✅ All validation checks passed!')) } else { - console.log(chalk.white(` Found: ${chalk.red(`${this.errors.length} error(s)`)} | ${chalk.yellow(`${this.warnings.length} warning(s)`)}`)) + log('main', 'VALIDATION', `Found: ${this.errors.length} error(s) | ${this.warnings.length} warning(s)`) if (this.errors.length > 0) { - console.log(chalk.red('\n ⚠️ CRITICAL ERRORS DETECTED')) - console.log(chalk.white(' → Bot will continue, but these issues may cause failures')) - console.log(chalk.white(' → Review errors above and fix them for stable operation')) - console.log(chalk.gray(' → If you believe these are false positives, you can ignore them')) + log('main', 'VALIDATION', 'Bot will continue, but issues may cause failures', 'warn') } else { - console.log(chalk.yellow('\n ⚠️ Warnings detected - review recommended')) - console.log(chalk.gray(' → Bot will continue normally')) + log('main', 'VALIDATION', 'Warnings detected - review recommended', 'warn') } - console.log(chalk.white('\n 📖 Full documentation: docs/index.md')) - console.log(chalk.gray(' → Proceeding with execution in 5 seconds...')) - - // Give user time to read (5 seconds for errors, 5 seconds for warnings) - await new Promise(resolve => setTimeout(resolve, 5000)) + log('main', 'VALIDATION', 'Full documentation: docs/index.md') + await new Promise(resolve => setTimeout(resolve, 3000)) } - - console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n')) } } diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 5a5e85b..f862e2e 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -3,20 +3,30 @@ import ms from 'ms' export class Util { async wait(ms: number): Promise { - // Safety check: prevent extremely long or negative waits - const MAX_WAIT_MS = 3600000 // 1 hour max - const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS) + const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits + const MIN_WAIT_MS = 0 - if (ms !== safeMs) { - console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`) + // Validate and clamp input + if (!Number.isFinite(ms)) { + throw new Error(`Invalid wait time: ${ms}. Must be a finite number.`) } + const safeMs = Math.min(Math.max(MIN_WAIT_MS, ms), MAX_WAIT_MS) + return new Promise((resolve) => { setTimeout(resolve, safeMs) }) } async waitRandom(minMs: number, maxMs: number): Promise { + 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) } @@ -37,13 +47,25 @@ export class Util { } randomNumber(min: number, max: number): number { + 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 } chunkArray(arr: T[], numChunks: number): T[][] { // Validate input to prevent division by zero or invalid chunks - if (numChunks <= 0) { - throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`) + if (!Number.isFinite(numChunks) || numChunks <= 0) { + throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive finite number.`) + } + + if (!Array.isArray(arr)) { + throw new Error('Invalid input: arr must be an array.') } if (arr.length === 0) { @@ -63,8 +85,12 @@ export class Util { } stringToMs(input: string | number): number { + if (typeof input !== 'string' && typeof input !== 'number') { + throw new Error('Invalid input type. Expected string or number.') + } + const milisec = ms(input.toString()) - if (!milisec) { + 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"') } return milisec