feat: Implement account suspension checks and improve error handling

- Added a method to check for account suspension using multiple detection methods in BrowserFunc.
- Refactored existing suspension checks to utilize the new method, reducing code duplication.
- Enhanced error handling in various functions to throw original errors instead of wrapping them.
- Improved environment variable parsing in constants to streamline validation.
- Updated login flow to optimize session restoration and error handling.
- Refined Axios request logic to include retry mechanisms for proxy authentication and network errors.
- Enhanced logging functionality to provide clearer output and error context.
- Improved utility functions with additional validation for input parameters.
This commit is contained in:
2025-11-03 21:21:13 +01:00
parent f1db62823c
commit 39b62a4190
9 changed files with 318 additions and 340 deletions

View File

@@ -15,10 +15,8 @@ class Browser {
} }
async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> { async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> {
// Optional automatic browser installation (set AUTO_INSTALL_BROWSERS=1)
if (process.env.AUTO_INSTALL_BROWSERS === '1') { if (process.env.AUTO_INSTALL_BROWSERS === '1') {
try { try {
// Dynamically import child_process to avoid overhead otherwise
const { execSync } = await import('child_process') const { execSync } = await import('child_process')
execSync('npx playwright install chromium', { stdio: 'ignore' }) execSync('npx playwright install chromium', { stdio: 'ignore' })
} catch (e) { } catch (e) {
@@ -28,28 +26,25 @@ class Browser {
let browser: import('rebrowser-playwright').Browser let browser: import('rebrowser-playwright').Browser
try { try {
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
const envForceHeadless = process.env.FORCE_HEADLESS === '1' 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 legacyHeadless = (this.bot.config as { headless?: boolean }).headless
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false) let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
if (this.bot.isBuyModeEnabled() && !envForceHeadless) { if (this.bot.isBuyModeEnabled() && !envForceHeadless) {
if (headlessValue !== false) { if (headlessValue !== false) {
const target = this.bot.getBuyModeTarget() 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 headlessValue = false
} }
const headless: boolean = Boolean(headlessValue)
const engineName = 'chromium' // current hard-coded engine const headless: boolean = Boolean(headlessValue)
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) const proxyConfig = this.buildPlaywrightProxy(proxy)
browser = await playwright.chromium.launch({ browser = await playwright.chromium.launch({
// Optional: uncomment to use Edge instead of Chromium
// channel: 'msedge',
headless, headless,
...(proxyConfig && { proxy: proxyConfig }), ...(proxyConfig && { proxy: proxyConfig }),
args: [ args: [
@@ -63,80 +58,64 @@ class Browser {
}) })
} catch (e: unknown) { } catch (e: unknown) {
const msg = (e instanceof Error ? e.message : String(e)) const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) { 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 { } else {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error') this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
} }
throw e throw e
} }
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).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 nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false } const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint) const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint() 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 })
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000 const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout)) context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
// Normalize viewport and page rendering so content fits typical screens
try { try {
const desktopViewport = { width: 1280, height: 800 }
const mobileViewport = { width: 390, height: 844 }
context.on('page', async (page) => { context.on('page', async (page) => {
try { try {
// Set a reasonable viewport size depending on device type const viewport = this.bot.isMobile
if (this.bot.isMobile) { ? { width: 390, height: 844 }
await page.setViewportSize(mobileViewport) : { width: 1280, height: 800 }
} else {
await page.setViewportSize(desktopViewport) await page.setViewportSize(viewport)
}
// Inject a tiny CSS to avoid gigantic scaling on some environments
await page.addInitScript(() => { await page.addInitScript(() => {
try { try {
const style = document.createElement('style') const style = document.createElement('style')
style.id = '__mrs_fit_style' style.id = '__mrs_fit_style'
style.textContent = ` style.textContent = `
html, body { overscroll-behavior: contain; } html, body { overscroll-behavior: contain; }
/* Mild downscale to keep content within window on very large DPI */
@media (min-width: 1000px) { @media (min-width: 1000px) {
html { zoom: 0.9 !important; } html { zoom: 0.9 !important; }
} }
` `
document.documentElement.appendChild(style) document.documentElement.appendChild(style)
} catch (e) { } catch {/* ignore */}
// Style injection failed - not critical, page will still function
}
}) })
} catch (e) { } 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') this.bot.log(this.bot.isMobile, 'BROWSER', `Page setup warning: ${e instanceof Error ? e.message : String(e)}`, 'warn')
} }
}) })
} catch (e) { } 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) await context.addCookies(sessionData.cookies)
// Persist fingerprint when feature is configured
if (saveFingerprint.mobile || saveFingerprint.desktop) { if (saveFingerprint.mobile || saveFingerprint.desktop) {
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint) 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 return context as BrowserContext
} }

View File

@@ -18,6 +18,45 @@ export default class BrowserFunc {
this.bot = bot 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<boolean> {
// 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 * Navigate the provided page to rewards homepage
@@ -46,32 +85,10 @@ export default class BrowserFunc {
} catch (error) { } catch (error) {
// Activities not found yet - check if it's because account is suspended // 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 isSuspended = await this.checkAccountSuspension(page, iteration)
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')
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) { 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!') 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 // 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') 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GO-HOME', 'An error occurred: ' + errorMessage, '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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(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') 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<string | null> { private async extractDashboardScript(page: Page): Promise<string | null> {
return await page.evaluate(() => { return await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script')) const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => const dashboardPatterns = ['var dashboard', 'dashboard=', 'dashboard :']
script.innerText.includes('var dashboard') ||
script.innerText.includes('dashboard=') || const targetScript = scripts.find(script => {
script.innerText.includes('dashboard :') const text = script.innerText
) return text && dashboardPatterns.some(pattern => text.includes(pattern))
return targetScript?.innerText ? targetScript.innerText : null })
return targetScript?.innerText || null
}) })
} }
@@ -213,10 +232,9 @@ export default class BrowserFunc {
private async parseDashboardFromScript(page: Page, scriptContent: string): Promise<DashboardData | null> { private async parseDashboardFromScript(page: Page, scriptContent: string): Promise<DashboardData | null> {
return await page.evaluate((scriptContent: string) => { return await page.evaluate((scriptContent: string) => {
const patterns = [ const patterns = [
/var dashboard = (\{.*?\});/s, /var\s+dashboard\s*=\s*(\{[\s\S]*?\});/,
/var dashboard=(\{.*?\});/s, /dashboard\s*=\s*(\{[\s\S]*?\});/,
/var\s+dashboard\s*=\s*(\{.*?\});/s, /var\s+dashboard\s*:\s*(\{[\s\S]*?\})\s*[,;]/
/dashboard\s*=\s*(\{[\s\S]*?\});/
] ]
for (const regex of patterns) { for (const regex of patterns) {
@@ -293,7 +311,7 @@ export default class BrowserFunc {
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(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') 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(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') 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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred: ' + errorMessage, '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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + errorMessage, '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) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred: ' + errorMessage, 'error') this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred: ' + errorMessage, 'error')
throw new Error('Close browser failed: ' + errorMessage) throw error
} }
} }
} }

View File

@@ -5,21 +5,18 @@
/** /**
* Parse environment variable as number with validation * 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 { function parseEnvNumber(key: string, defaultValue: number, min: number, max: number): number {
const raw = process.env[key] const raw = process.env[key]
if (!raw) return defaultValue if (!raw) return defaultValue
const parsed = Number(raw) const parsed = Number(raw)
if (isNaN(parsed)) { if (isNaN(parsed) || parsed < min || parsed > max) return defaultValue
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 return parsed
} }
@@ -85,5 +82,6 @@ export const DISCORD = {
COLOR_ORANGE: 0xFFA500, COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB, COLOR_BLUE: 0x3498DB,
COLOR_GREEN: 0x00D26A, 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' 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 } as const

View File

@@ -27,20 +27,16 @@ const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
const DEFAULT_TIMEOUTS = { const DEFAULT_TIMEOUTS = {
loginMaxMs: (() => { loginMaxMs: (() => {
const val = Number(process.env.LOGIN_MAX_WAIT_MS || 300000) const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000)
if (isNaN(val) || val < 10000 || val > 600000) { return (isNaN(val) || val < 10000 || val > 600000) ? 180000 : val
console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 300000ms`)
return 300000
}
return val
})(), })(),
short: 200, // Reduced from 500ms short: 200,
medium: 800, // Reduced from 1500ms medium: 800,
long: 1500, // Reduced from 3000ms long: 1500,
oauthMaxMs: 360000, oauthMaxMs: 180000,
portalWaitMs: 15000, portalWaitMs: 15000,
elementCheck: 100, // Fast element detection elementCheck: 100,
fastPoll: 500 // Fast polling interval fastPoll: 500
} }
// Security pattern bundle // Security pattern bundle
@@ -77,12 +73,14 @@ export class Login {
private lastTotpSubmit = 0 private lastTotpSubmit = 0
private totpAttempts = 0 private totpAttempts = 0
constructor(bot: MicrosoftRewardsBot) { this.bot = bot } constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
this.cleanupCompromisedInterval()
}
// --------------- Public API --------------- // --------------- Public API ---------------
async login(page: Page, email: string, password: string, totpSecret?: string) { async login(page: Page, email: string, password: string, totpSecret?: string) {
try { try {
// Clear any existing intervals from previous runs to prevent memory leaks
this.cleanupCompromisedInterval() this.cleanupCompromisedInterval()
this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process') this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
@@ -92,22 +90,19 @@ export class Login {
const resumed = await this.tryReuseExistingSession(page) const resumed = await this.tryReuseExistingSession(page)
if (resumed) { if (resumed) {
// OPTIMIZATION: Skip Bing verification if already on rewards page
const needsVerification = !page.url().includes('rewards.bing.com') const needsVerification = !page.url().includes('rewards.bing.com')
if (needsVerification) { if (needsVerification) {
await this.verifyBingContext(page) await this.verifyBingContext(page)
} }
await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile) 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 this.currentTotpSecret = undefined
return return
} }
// Full login flow needed
await page.goto('https://rewards.bing.com/signin', { waitUntil: 'domcontentloaded' }) await page.goto('https://rewards.bing.com/signin', { waitUntil: 'domcontentloaded' })
await this.disableFido(page) await this.disableFido(page)
// OPTIMIZATION: Parallel checks instead of sequential
const [, , portalCheck] = await Promise.allSettled([ const [, , portalCheck] = await Promise.allSettled([
this.bot.browser.utils.reloadBadPage(page), this.bot.browser.utils.reloadBadPage(page),
this.tryAutoTotp(page, 'initial landing'), this.tryAutoTotp(page, 'initial landing'),
@@ -123,7 +118,6 @@ export class Login {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated') this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated')
} }
// OPTIMIZATION: Only verify Bing if needed
const needsBingVerification = !page.url().includes('rewards.bing.com') const needsBingVerification = !page.url().includes('rewards.bing.com')
if (needsBingVerification) { if (needsBingVerification) {
await this.verifyBingContext(page) await this.verifyBingContext(page)
@@ -141,12 +135,10 @@ export class Login {
} }
async getMobileAccessToken(page: Page, email: string, totpSecret?: string) { async getMobileAccessToken(page: Page, email: string, totpSecret?: string) {
// Store TOTP secret for this mobile auth session
this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
this.lastTotpSubmit = 0 this.lastTotpSubmit = 0
this.totpAttempts = 0 this.totpAttempts = 0
// Reuse same FIDO disabling
await this.disableFido(page) await this.disableFido(page)
const url = new URL(this.authBaseUrl) const url = new URL(this.authBaseUrl)
url.searchParams.set('response_type', 'code') url.searchParams.set('response_type', 'code')
@@ -167,39 +159,34 @@ export class Login {
while (Date.now() - start < DEFAULT_TIMEOUTS.oauthMaxMs) { while (Date.now() - start < DEFAULT_TIMEOUTS.oauthMaxMs) {
checkCount++ checkCount++
// OPTIMIZATION: Check URL first (fastest check)
const u = new URL(page.url()) const u = new URL(page.url())
if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') { if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') {
code = u.searchParams.get('code') || '' code = u.searchParams.get('code') || ''
if (code) break if (code) break
} }
// OPTIMIZATION: Handle prompts and TOTP in parallel when possible if (checkCount % 3 === 0) {
if (checkCount % 3 === 0) { // Every 3rd iteration
await Promise.allSettled([ await Promise.allSettled([
this.handlePasskeyPrompts(page, 'oauth'), this.handlePasskeyPrompts(page, 'oauth'),
this.tryAutoTotp(page, 'mobile-oauth') this.tryAutoTotp(page, 'mobile-oauth')
]) ])
} }
// Progress log every 30 seconds
const now = Date.now() const now = Date.now()
if (now - lastLogTime > 30000) { if (now - lastLogTime > 30000) {
const elapsed = Math.round((now - start) / 1000) 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 lastLogTime = now
} }
// OPTIMIZATION: Adaptive polling - faster initially, slower after
const pollDelay = Date.now() - start < 30000 ? 800 : 1500 const pollDelay = Date.now() - start < 30000 ? 800 : 1500
await this.bot.utils.wait(pollDelay) await this.bot.utils.wait(pollDelay)
} }
if (!code) { if (!code) {
const elapsed = Math.round((Date.now() - start) / 1000) const elapsed = Math.round((Date.now() - start) / 1000)
const currentUrl = page.url() 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') 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`)
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 received in ${Math.round((Date.now() - start) / 1000)}s`) this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`)
@@ -210,7 +197,15 @@ export class Login {
form.append('code', code) form.append('code', code)
form.append('redirect_uri', this.redirectUrl) 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 = { const req: AxiosRequestConfig = {
url: this.tokenUrl, url: this.tokenUrl,
method: 'POST', method: 'POST',
@@ -218,16 +213,6 @@ export class Login {
data: form.toString() 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) const retry = new Retry(this.bot.config.retryPolicy)
try { try {
const resp = await retry.run( const resp = await retry.run(
@@ -236,11 +221,9 @@ export class Login {
) )
const data: OAuth = resp.data const data: OAuth = resp.data
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now()-start)/1000)}s`) 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 this.currentTotpSecret = undefined
return data.access_token return data.access_token
} catch (error) { } catch (error) {
// Clear TOTP secret on error too
this.currentTotpSecret = undefined this.currentTotpSecret = undefined
const err = error as { response?: { status?: number }; message?: string } const err = error as { response?: { status?: number }; message?: string }
const statusCode = err.response?.status const statusCode = err.response?.status

View File

@@ -367,49 +367,25 @@ export class MicrosoftRewardsBot {
} }
private printBanner() { private printBanner() {
// Only print once (primary process or single cluster execution)
if (this.config.clusters > 1 && !cluster.isPrimary) return 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 version = this.getVersion()
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner const mode = this.buyMode.enabled ? 'Manual Mode' : 'Automated Mode'
console.log(displayBanner)
console.log('─'.repeat(60)) log('main', 'BANNER', `Microsoft Rewards Bot v${version} - ${mode}`)
log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`)
if (this.buyMode.enabled) { if (this.buyMode.enabled) {
console.log(` Version ${version} | PID ${process.pid} | Manual Session`) log('main', 'BANNER', `Target: ${this.buyMode.email || 'First account'}`)
console.log(` Target: ${this.buyMode.email || 'First account'}`)
} else { } else {
console.log(` Version ${version} | PID ${process.pid} | Workers: ${this.config.clusters}`)
const upd = this.config.update || {} const upd = this.config.update || {}
const updTargets: string[] = [] const updTargets: string[] = []
if (upd.git !== false) updTargets.push('Git') if (upd.git !== false) updTargets.push('Git')
if (upd.docker) updTargets.push('Docker') if (upd.docker) updTargets.push('Docker')
if (updTargets.length > 0) { if (updTargets.length > 0) {
console.log(` Auto-Update: ${updTargets.join(', ')}`) log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`)
} }
console.log(' Scheduler: External (see docs)')
} }
console.log('─'.repeat(60) + '\n')
} }
private getVersion(): string { private getVersion(): string {
@@ -478,27 +454,23 @@ export class MicrosoftRewardsBot {
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') 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 // Optional: restart crashed worker (basic heuristic) if crashRecovery allows
try {
const cr = this.config.crashRecovery const cr = this.config.crashRecovery
if (cr?.restartFailedWorker && code !== 0 && worker.id) { if (cr?.restartFailedWorker && code !== 0 && worker.id) {
const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0 const attempts = (worker as { _restartAttempts?: number })._restartAttempts || 0
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) { if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
(worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1 (worker as { _restartAttempts?: number })._restartAttempts = attempts + 1
log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow') log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn')
// CRITICAL FIX: Re-send the original chunk to the new worker
const originalChunk = workerChunkMap.get(worker.id) const originalChunk = workerChunkMap.get(worker.id)
const newW = cluster.fork() const newW = cluster.fork()
if (originalChunk && originalChunk.length > 0 && newW.id) { if (originalChunk && originalChunk.length > 0 && newW.id) {
// Send the accounts to the new worker (newW as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk })
(newW as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk })
// Update mapping with new worker ID
workerChunkMap.set(newW.id, originalChunk) workerChunkMap.set(newW.id, originalChunk)
workerChunkMap.delete(worker.id) workerChunkMap.delete(worker.id)
log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`, 'log', 'green') log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`)
} else { } else {
log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker (chunk not found)', 'warn', 'yellow') log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker', 'warn')
} }
newW.on('message', (msg: unknown) => { newW.on('message', (msg: unknown) => {
@@ -507,9 +479,6 @@ export class MicrosoftRewardsBot {
}) })
} }
} }
} catch (e) {
log('main','CRASH-RECOVERY',`Failed to respawn worker: ${e instanceof Error ? e.message : e}`, 'error')
}
// Check if all workers have exited // Check if all workers have exited
if (this.activeWorkers === 0) { if (this.activeWorkers === 0) {
@@ -591,13 +560,6 @@ export class MicrosoftRewardsBot {
this.axios = new Axios(account.proxy) this.axios = new Axios(account.proxy)
const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1' 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) { if (this.config.dryRun) {
log('main', 'DRY-RUN', `Dry run: skipping automation for ${account.email}`) 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) this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.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 mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -645,7 +607,7 @@ export class MicrosoftRewardsBot {
this.recordRiskEvent('ban_hint', 9, bd.reason) this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.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]) const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
@@ -656,7 +618,7 @@ export class MicrosoftRewardsBot {
} else if (desktopResult.status === 'rejected') { } 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')
this.recordRiskEvent('error', 6, `desktop-rejected:${shortErr(desktopResult.reason)}`) 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 // Handle mobile result
@@ -666,7 +628,7 @@ export class MicrosoftRewardsBot {
} else if (mobileResult.status === 'rejected') { } 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')
this.recordRiskEvent('error', 6, `mobile-rejected:${shortErr(mobileResult.reason)}`) 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 { } else {
// Sequential execution with safety checks // Sequential execution with safety checks
@@ -686,7 +648,7 @@ export class MicrosoftRewardsBot {
this.recordRiskEvent('ban_hint', 9, bd.reason) this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.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) { if (desktopResult) {
desktopInitial = desktopResult.initialPoints desktopInitial = desktopResult.initialPoints
@@ -708,7 +670,7 @@ export class MicrosoftRewardsBot {
this.recordRiskEvent('ban_hint', 9, bd.reason) this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.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) { if (mobileResult) {
mobileInitial = mobileResult.initialPoints mobileInitial = mobileResult.initialPoints
@@ -1338,6 +1300,14 @@ function shortErr(e: unknown): string {
return s.substring(0, 120) 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 { function formatDuration(ms: number): string {
if (!ms || ms < 1000) return `${ms}ms` if (!ms || ms < 1000) return `${ms}ms`
const sec = Math.floor(ms / 1000) const sec = Math.floor(ms / 1000)

View File

@@ -22,7 +22,7 @@ class AxiosClient {
private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent { private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent {
const { proxyUrl, protocol } = this.buildProxyUrl(proxyConfig) const { proxyUrl, protocol } = this.buildProxyUrl(proxyConfig)
const normalized = protocol.replace(/:$/, '') const normalized = protocol.replace(/:$/, '').toLowerCase()
switch (normalized) { switch (normalized) {
case 'http': case 'http':
@@ -80,7 +80,7 @@ class AxiosClient {
return { proxyUrl: parsedUrl.toString(), protocol: parsedUrl.protocol } 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<AxiosResponse> { public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
if (bypassProxy) { if (bypassProxy) {
const bypassInstance = axios.create() const bypassInstance = axios.create()
@@ -95,25 +95,16 @@ class AxiosClient {
return await this.instance.request(config) return await this.instance.request(config)
} catch (err: unknown) { } catch (err: unknown) {
lastError = err lastError = err
const axiosErr = err as AxiosError | undefined
// Detect HTTP proxy auth failures (status 407) and retry without proxy // Handle HTTP 407 Proxy Authentication Required
if (axiosErr && axiosErr.response && axiosErr.response.status === 407) { if (this.isProxyAuthError(err)) {
if (attempt < maxAttempts) { // Retry without proxy on auth failure
await this.sleep(1000 * attempt) // Exponential backoff
}
const bypassInstance = axios.create() const bypassInstance = axios.create()
return bypassInstance.request(config) return bypassInstance.request(config)
} }
// If proxied request fails with common proxy/network errors, retry with backoff // Handle retryable network errors
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined if (this.isRetryableError(err)) {
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) {
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s, etc. // Exponential backoff: 1s, 2s, 4s, etc.
const delayMs = 1000 * Math.pow(2, attempt - 1) const delayMs = 1000 * Math.pow(2, attempt - 1)
@@ -133,6 +124,34 @@ class AxiosClient {
throw lastError 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<void> { private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise(resolve => setTimeout(resolve, ms))
} }

View File

@@ -16,9 +16,11 @@ type WebhookBuffer = {
const webhookBuffers = new Map<string, WebhookBuffer>() const webhookBuffers = new Map<string, WebhookBuffer>()
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks // 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 now = Date.now()
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
for (const [url, buf] of webhookBuffers.entries()) { for (const [url, buf] of webhookBuffers.entries()) {
if (!buf.sending && buf.lines.length === 0) { 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 { function getBuffer(url: string): WebhookBuffer {
let buf = webhookBuffers.get(url) let buf = webhookBuffers.get(url)
@@ -87,28 +94,25 @@ async function sendBatch(url: string, buf: WebhookBuffer) {
function determineColorFromContent(content: string): number { function determineColorFromContent(content: string): number {
const lower = content.toLowerCase() 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')) { if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) {
return DISCORD.COLOR_RED return DISCORD.COLOR_RED
} }
// Errors - Dark Red
if (lower.includes('[error]') || lower.includes('✗')) { if (lower.includes('[error]') || lower.includes('✗')) {
return DISCORD.COLOR_CRIMSON return DISCORD.COLOR_CRIMSON
} }
// Warnings - Orange/Yellow
if (lower.includes('[warn]') || lower.includes('⚠')) { if (lower.includes('[warn]') || lower.includes('⚠')) {
return DISCORD.COLOR_ORANGE return DISCORD.COLOR_ORANGE
} }
// Success - Green
if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) { if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) {
return DISCORD.COLOR_GREEN return DISCORD.COLOR_GREEN
} }
// Info/Main - Blue
if (lower.includes('[main]')) { if (lower.includes('[main]')) {
return DISCORD.COLOR_BLUE return DISCORD.COLOR_BLUE
} }
// Default - Gray
return 0x95A5A6 // Gray return 0x95A5A6
} }
function enqueueWebhookLog(url: string, line: string) { 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(...)` // Return an Error when logging an error so callers can `throw log(...)`
if (type === 'error') { if (type === 'error') {
// CommunityReporter disabled per project policy
return new Error(cleanStr) return new Error(cleanStr)
} }
} }

View File

@@ -3,6 +3,7 @@ import path from 'path'
import chalk from 'chalk' import chalk from 'chalk'
import { Config } from '../interface/Config' import { Config } from '../interface/Config'
import { Account } from '../interface/Account' import { Account } from '../interface/Account'
import { log } from './Logger'
interface ValidationError { interface ValidationError {
severity: 'error' | 'warning' severity: 'error' | 'warning'
@@ -22,9 +23,7 @@ export class StartupValidator {
* Displays errors and warnings but lets execution continue. * Displays errors and warnings but lets execution continue.
*/ */
async validate(config: Config, accounts: Account[]): Promise<boolean> { async validate(config: Config, accounts: Account[]): Promise<boolean> {
console.log(chalk.cyan('\n═══════════════════════════════════════════════════════════════')) log('main', 'STARTUP', 'Running configuration validation...')
console.log(chalk.cyan(' 🔍 STARTUP VALIDATION - Checking Configuration'))
console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n'))
// Run all validation checks // Run all validation checks
this.validateAccounts(accounts) this.validateAccounts(accounts)
@@ -621,62 +620,45 @@ export class StartupValidator {
} }
private async displayResults(): Promise<void> { private async displayResults(): Promise<void> {
// Display errors
if (this.errors.length > 0) { 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) => { 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) { if (err.fix) {
console.log(chalk.yellow(` 💡 Fix: ${err.fix}`)) log('main', 'VALIDATION', chalk.yellow(` Fix: ${err.fix}`), 'warn')
} }
if (err.docsLink) { 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) { if (this.warnings.length > 0) {
console.log(chalk.yellow('\n⚠️ WARNINGS:\n')) log('main', 'VALIDATION', chalk.yellow('⚠️ WARNINGS:'), 'warn')
this.warnings.forEach((warn, index) => { 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) { if (warn.fix) {
console.log(chalk.gray(` 💡 Suggestion: ${warn.fix}`)) log('main', 'VALIDATION', ` Suggestion: ${warn.fix}`)
} }
if (warn.docsLink) { 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) { if (this.errors.length === 0 && this.warnings.length === 0) {
console.log(chalk.green(' ✅ All validation checks passed! Configuration looks good.')) log('main', 'VALIDATION', chalk.green('✅ All validation checks passed!'))
console.log(chalk.gray(' → Starting bot execution...'))
} else { } 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) { if (this.errors.length > 0) {
console.log(chalk.red('\n ⚠️ CRITICAL ERRORS DETECTED')) log('main', 'VALIDATION', 'Bot will continue, but issues may cause failures', 'warn')
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'))
} else { } else {
console.log(chalk.yellow('\n ⚠️ Warnings detected - review recommended')) log('main', 'VALIDATION', 'Warnings detected - review recommended', 'warn')
console.log(chalk.gray(' → Bot will continue normally'))
} }
console.log(chalk.white('\n 📖 Full documentation: docs/index.md')) log('main', 'VALIDATION', 'Full documentation: docs/index.md')
console.log(chalk.gray(' → Proceeding with execution in 5 seconds...')) await new Promise(resolve => setTimeout(resolve, 3000))
// Give user time to read (5 seconds for errors, 5 seconds for warnings)
await new Promise(resolve => setTimeout(resolve, 5000))
} }
console.log(chalk.cyan('═══════════════════════════════════════════════════════════════\n'))
} }
} }

View File

@@ -3,20 +3,30 @@ import ms from 'ms'
export class Util { export class Util {
async wait(ms: number): Promise<void> { async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits
const MAX_WAIT_MS = 3600000 // 1 hour max const MIN_WAIT_MS = 0
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) { // Validate and clamp input
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`) 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<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(resolve, safeMs) setTimeout(resolve, safeMs)
}) })
} }
async waitRandom(minMs: number, maxMs: number): Promise<void> { async waitRandom(minMs: number, maxMs: number): Promise<void> {
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) const delta = this.randomNumber(minMs, maxMs)
return this.wait(delta) return this.wait(delta)
} }
@@ -37,13 +47,25 @@ export class Util {
} }
randomNumber(min: number, max: number): number { 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 return Math.floor(Math.random() * (max - min + 1)) + min
} }
chunkArray<T>(arr: T[], numChunks: number): T[][] { chunkArray<T>(arr: T[], numChunks: number): T[][] {
// Validate input to prevent division by zero or invalid chunks // Validate input to prevent division by zero or invalid chunks
if (numChunks <= 0) { if (!Number.isFinite(numChunks) || numChunks <= 0) {
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`) 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) { if (arr.length === 0) {
@@ -63,8 +85,12 @@ export class Util {
} }
stringToMs(input: string | number): number { 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()) 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"') 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 return milisec