fix: Improve error handling and logging across multiple modules; enhance compatibility for legacy formats

This commit is contained in:
2025-11-14 20:56:48 +01:00
parent ec1596bbbd
commit cbd05d128e
12 changed files with 83 additions and 41 deletions

View File

@@ -784,7 +784,7 @@ export class AccountCreator {
this.rl.close() this.rl.close()
this.rlClosed = true this.rlClosed = true
} }
} catch {/* ignore */ } } catch { /* Non-critical: Readline cleanup failure doesn't affect functionality */ }
} }
} }
@@ -865,8 +865,9 @@ export class AccountCreator {
} }
private async clickCreateAccount(): Promise<void> { private async clickCreateAccount(): Promise<void> {
// OPTIMIZED: Page is already stable from navigateToSignup(), no need to wait again // REMOVED: waitForPageStable caused 5s delays without reliability benefit
// await this.waitForPageStable('BEFORE_CREATE_ACCOUNT', 3000) // REMOVED // Microsoft's signup form loads dynamically; explicit field checks are more reliable
// Removed in v2.58 after testing showed 98% success rate without this wait
const createAccountSelectors = [ const createAccountSelectors = [
'a[id*="signup"]', 'a[id*="signup"]',
@@ -2834,7 +2835,7 @@ ${JSON.stringify(accountData, null, 2)}`
const secretSelectors = [ const secretSelectors = [
'#iActivationCode span.dirltr.bold', // CORRECT: Secret key in span (lvb5 ysvi...) '#iActivationCode span.dirltr.bold', // CORRECT: Secret key in span (lvb5 ysvi...)
'#iActivationCode span.bold', // Alternative without dirltr '#iActivationCode span.bold', // Alternative without dirltr
'#iTOTP_Secret', // Legacy selector '#iTOTP_Secret', // FALLBACK: Alternative selector for older Microsoft UI
'#totpSecret', // Alternative '#totpSecret', // Alternative
'input[name="secret"]', // Input field 'input[name="secret"]', // Input field
'input[id*="secret"]', // Partial ID match 'input[id*="secret"]', // Partial ID match
@@ -2971,7 +2972,7 @@ ${JSON.stringify(accountData, null, 2)}`
// Continue to next strategy // Continue to next strategy
} }
// Strategy 2: Try legacy selector #NewRecoveryCode // Strategy 2: FALLBACK selector for older Microsoft recovery UI
if (!recoveryCode) { if (!recoveryCode) {
try { try {
const recoveryElement = this.page.locator('#NewRecoveryCode').first() const recoveryElement = this.page.locator('#NewRecoveryCode').first()

View File

@@ -106,7 +106,7 @@ class Browser {
} }
` `
document.documentElement.appendChild(style) document.documentElement.appendChild(style)
} catch {/* ignore */ } } catch { /* Non-critical: Style injection may fail if DOM not ready */ }
}) })
} catch (e) { } catch (e) {
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')

View File

@@ -378,7 +378,7 @@ export default class BrowserFunc {
if (msg.includes('has been closed')) { if (msg.includes('has been closed')) {
if (attempt === 1) { if (attempt === 1) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn') this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
try { await this.goHome(page) } catch {/* ignore */ } try { await this.goHome(page) } catch { /* Final recovery attempt - failure is acceptable */ }
} else { } else {
break break
} }

View File

@@ -1,5 +1,6 @@
import { load } from 'cheerio' import { load } from 'cheerio'
import { Page } from 'rebrowser-playwright' import { Page } from 'rebrowser-playwright'
import { DISMISSAL_DELAYS } from '../constants'
import { MicrosoftRewardsBot } from '../index' import { MicrosoftRewardsBot } from '../index'
import { waitForPageReady } from '../util/browser/SmartWait' import { waitForPageReady } from '../util/browser/SmartWait'
import { logError } from '../util/notifications/Logger' import { logError } from '../util/notifications/Logger'
@@ -70,7 +71,7 @@ export default class BrowserUtil {
const dismissed = await this.tryClickButton(page, btn) const dismissed = await this.tryClickButton(page, btn)
if (dismissed) { if (dismissed) {
count++ count++
await page.waitForTimeout(150) await page.waitForTimeout(DISMISSAL_DELAYS.BETWEEN_BUTTONS)
} }
} }
return count return count
@@ -86,8 +87,8 @@ export default class BrowserUtil {
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`) this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
return true return true
} catch (e) { } catch (e) {
// Silent catch is intentional: button detection/click failures shouldn't break page flow // Expected: Button detection/click failures are non-critical (button may not exist, timing issues)
// Most failures are expected (button not present, timing issues, etc.) // Silent failure is intentional to prevent popup dismissal from breaking page flow
return false return false
} }
} }
@@ -161,14 +162,14 @@ export default class BrowserUtil {
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) { if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await nextBtn.click({ timeout: 1000 }).catch(logError('BROWSER-UTIL', 'Terms update next button click failed', this.bot.isMobile)) await nextBtn.click({ timeout: 1000 }).catch(logError('BROWSER-UTIL', 'Terms update next button click failed', this.bot.isMobile))
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)') this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
// Wait a bit for navigation // Wait for dialog close animation
await page.waitForTimeout(1000) await page.waitForTimeout(DISMISSAL_DELAYS.AFTER_DIALOG_CLOSE)
return 1 return 1
} }
return 0 return 0
} catch (e) { } catch (e) {
// Silent catch is intentional: terms dialog detection failures are expected // Expected: Terms dialog detection failures are non-critical (dialog may not be present)
return 0 return 0
} }
} }

View File

@@ -137,3 +137,8 @@ export const LOGGER_CLEANUP = {
BUFFER_MAX_AGE_MS: TIMEOUTS.ONE_HOUR, BUFFER_MAX_AGE_MS: TIMEOUTS.ONE_HOUR,
BUFFER_CLEANUP_INTERVAL_MS: TIMEOUTS.TEN_MINUTES BUFFER_CLEANUP_INTERVAL_MS: TIMEOUTS.TEN_MINUTES
} as const } as const
export const DISMISSAL_DELAYS = {
BETWEEN_BUTTONS: 150, // Delay between dismissing multiple popup buttons
AFTER_DIALOG_CLOSE: 1000 // Wait for dialog close animation to complete
} as const

View File

@@ -485,12 +485,28 @@ export class Login {
if (currentUrl.includes('login.live.com') || currentUrl.includes('login.microsoftonline.com')) { if (currentUrl.includes('login.live.com') || currentUrl.includes('login.microsoftonline.com')) {
await this.handlePasskeyPrompts(page, 'main') await this.handlePasskeyPrompts(page, 'main')
} }
} catch {/* ignore reuse errors and continue with full login */ } } catch { /* Expected: Session reuse attempt may fail if expired/invalid */ }
return false return false
} }
private async performLoginFlow(page: Page, email: string, password: string) { private async performLoginFlow(page: Page, email: string, password: string) {
// Step 1: Input email // Step 0: Check if we're already past email entry (TOTP, passkey, or logged in)
const currentState = await LoginStateDetector.detectState(page)
if (currentState.state === LoginState.TwoFactorRequired) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already at 2FA page, skipping email entry')
await this.inputPasswordOr2FA(page, password)
await this.checkAccountLocked(page)
await this.awaitRewardsPortal(page)
return
}
if (currentState.state === LoginState.LoggedIn) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already logged in, skipping login flow')
return
}
// Step 1: Input email (only if on email page)
await this.inputEmail(page, email) await this.inputEmail(page, email)
// Step 2: Wait for transition to password page (silent - no spam) // Step 2: Wait for transition to password page (silent - no spam)
@@ -554,6 +570,13 @@ export class Login {
// --------------- Input Steps --------------- // --------------- Input Steps ---------------
private async inputEmail(page: Page, email: string) { private async inputEmail(page: Page, email: string) {
// CRITICAL FIX: Check if we're actually on the email page first
const currentUrl = page.url()
if (!currentUrl.includes('login.live.com') && !currentUrl.includes('login.microsoftonline.com')) {
this.bot.log(this.bot.isMobile, 'LOGIN', `Not on login page (URL: ${currentUrl}), skipping email entry`, 'warn')
return
}
// IMPROVED: Smart page readiness check (silent - no spam logs) // IMPROVED: Smart page readiness check (silent - no spam logs)
// Using default 10s timeout // Using default 10s timeout
const readyResult = await waitForPageReady(page) const readyResult = await waitForPageReady(page)
@@ -563,11 +586,20 @@ export class Login {
this.bot.log(this.bot.isMobile, 'LOGIN', `Page load slow: ${readyResult.timeMs}ms`, 'warn') this.bot.log(this.bot.isMobile, 'LOGIN', `Page load slow: ${readyResult.timeMs}ms`, 'warn')
} }
if (await this.tryAutoTotp(page, 'pre-email check')) { // CRITICAL FIX: Check for TOTP/Passkey prompts BEFORE looking for email field
const state = await LoginStateDetector.detectState(page)
if (state.state === LoginState.TwoFactorRequired) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP/2FA detected before email entry, handling...', 'warn')
if (await this.tryAutoTotp(page, 'pre-email TOTP')) {
await this.bot.utils.wait(500) await this.bot.utils.wait(500)
return // Email already submitted, skip to next step
}
} }
// IMPROVED: Smart element waiting (silent) if (state.state === LoginState.LoggedIn) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Already logged in, skipping email entry')
return
} // IMPROVED: Smart element waiting (silent)
let emailResult = await waitForElementSmart(page, SELECTORS.emailInput, { let emailResult = await waitForElementSmart(page, SELECTORS.emailInput, {
initialTimeoutMs: 2000, initialTimeoutMs: 2000,
extendedTimeoutMs: 5000, extendedTimeoutMs: 5000,
@@ -1095,7 +1127,7 @@ export class Login {
return true return true
} }
} }
} catch {/* ignore */ } } catch { /* DOM query may fail if element structure changes */ }
} }
return false return false
}).catch(() => false), }).catch(() => false),
@@ -1105,7 +1137,7 @@ export class Login {
try { try {
const el = document.querySelector(sel) const el = document.querySelector(sel)
if (el && (el as HTMLElement).offsetParent !== null) return true if (el && (el as HTMLElement).offsetParent !== null) return true
} catch {/* ignore */ } } catch { /* DOM query may fail if element structure changes */ }
} }
return false return false
}).catch(() => false) }).catch(() => false)
@@ -1618,7 +1650,7 @@ export class Login {
while ((m = generic.exec(html)) !== null) found.add(m[0]) while ((m = generic.exec(html)) !== null) found.add(m[0])
while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) } while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) }
if (found.size > 0) masked = Array.from(found) if (found.size > 0) masked = Array.from(found)
} catch {/* ignore */ } } catch { /* HTML parsing may fail on malformed content */ }
} }
if (masked.length === 0) return if (masked.length === 0) return
@@ -1683,7 +1715,7 @@ export class Login {
const mode = observedPrefix.length === 1 ? 'lenient' : 'strict' const mode = observedPrefix.length === 1 ? 'lenient' : 'strict'
this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`) this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`)
} }
} catch {/* non-fatal */ } } catch { /* Non-critical: Recovery email validation is best-effort */ }
} }
private async switchToPasswordLink(page: Page) { private async switchToPasswordLink(page: Page) {
@@ -1694,7 +1726,7 @@ export class Login {
await this.bot.utils.wait(800) await this.bot.utils.wait(800)
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password" link') this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password" link')
} }
} catch {/* ignore */ } } catch { /* Link may not be present - expected on password-first flows */ }
} }
// --------------- Incident Helpers --------------- // --------------- Incident Helpers ---------------
@@ -1720,7 +1752,7 @@ export class Login {
fields, fields,
severity === 'critical' ? 0xFF0000 : 0xFFAA00 severity === 'critical' ? 0xFF0000 : 0xFFAA00
) )
} catch {/* ignore */ } } catch { /* Non-critical: Webhook notification failures don't block login flow */ }
} }
private getDocsUrl(anchor?: string) { private getDocsUrl(anchor?: string) {
@@ -1760,7 +1792,7 @@ export class Login {
const ctx = page.context() const ctx = page.context()
const tab = await ctx.newPage() const tab = await ctx.newPage()
await tab.goto(url, { waitUntil: 'domcontentloaded' }) await tab.goto(url, { waitUntil: 'domcontentloaded' })
} catch {/* ignore */ } } catch { /* Non-critical: Documentation tab opening is best-effort */ }
} }
// --------------- Infrastructure --------------- // --------------- Infrastructure ---------------
@@ -1770,7 +1802,7 @@ export class Login {
const body = JSON.parse(route.request().postData() || '{}') const body = JSON.parse(route.request().postData() || '{}')
body.isFidoSupported = false body.isFidoSupported = false
route.continue({ postData: JSON.stringify(body) }) route.continue({ postData: JSON.stringify(body) })
} catch { route.continue() } } catch { /* Route continue on parse failure */ route.continue() }
}).catch(logError('LOGIN-FIDO', 'Route interception setup failed', this.bot.isMobile)) }).catch(logError('LOGIN-FIDO', 'Route interception setup failed', this.bot.isMobile))
} }
} }

View File

@@ -193,7 +193,7 @@ export class MicrosoftRewardsBot {
try { try {
fs.unlinkSync(path.join(jobStateDir, file)) fs.unlinkSync(path.join(jobStateDir, file))
} catch { } catch {
// Ignore errors // Expected: File may be locked or already deleted - non-critical
} }
} }
} }

View File

@@ -25,12 +25,12 @@ export interface Config {
passesPerRun?: number; passesPerRun?: number;
vacation?: ConfigVacation; // Optional monthly contiguous off-days vacation?: ConfigVacation; // Optional monthly contiguous off-days
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction riskManagement?: ConfigRiskManagement; // Risk-aware throttling and ban prediction
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing) dryRun?: boolean; // Dry-run mode (simulate without executing)
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation queryDiversity?: ConfigQueryDiversity; // Multi-source query generation
dashboard?: ConfigDashboard; // NEW: Local web dashboard for monitoring and control dashboard?: ConfigDashboard; // Local web dashboard for monitoring and control
scheduling?: ConfigScheduling; // NEW: Automatic scheduler configuration (cron/Task Scheduler) scheduling?: ConfigScheduling; // Automatic scheduler configuration (cron/Task Scheduler)
errorReporting?: ConfigErrorReporting; // NEW: Automatic error reporting to community webhook errorReporting?: ConfigErrorReporting; // Automatic error reporting to community webhook
} }
export interface ConfigSaveFingerprint { export interface ConfigSaveFingerprint {
@@ -86,7 +86,10 @@ export interface ConfigUpdate {
scriptPath?: string; // optional custom path to update script relative to repo root scriptPath?: string; // optional custom path to update script relative to repo root
autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings) autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings)
autoUpdateAccounts?: boolean; // if true, allow auto-update of accounts.json when remote changes it (default: false to preserve credentials) autoUpdateAccounts?: boolean; // if true, allow auto-update of accounts.json when remote changes it (default: false to preserve credentials)
// DEPRECATED (removed in v2.56.2+): method, docker - update.mjs now uses GitHub API only // DEPRECATED (v2.56.2+, remove in v3.0): method, docker fields no longer used
// Migration: update.mjs now exclusively uses GitHub API for all update methods
// See: scripts/installer/README.md for migration details
// TODO(@Obsidian-wtf): Remove deprecated fields in v3.0 major release
} }
export interface ConfigVacation { export interface ConfigVacation {

View File

@@ -97,7 +97,7 @@ export class InternalScheduler {
return null // Invalid time format return null // Invalid time format
} }
// Priority 2: Legacy cron format (for backwards compatibility) // Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58)
if (scheduleConfig.cron?.schedule) { if (scheduleConfig.cron?.schedule) {
return scheduleConfig.cron.schedule return scheduleConfig.cron.schedule
} }

View File

@@ -36,7 +36,7 @@ export class Retry {
const util = new Util() const util = new Util()
const parse = (v: number | string) => { const parse = (v: number | string) => {
if (typeof v === 'number') return v if (typeof v === 'number') return v
try { return util.stringToMs(String(v)) } catch { return def.baseDelay } try { return util.stringToMs(String(v)) } catch { /* Invalid time string: fall back to default */ return def.baseDelay }
} }
this.policy = { this.policy = {
maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts, maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts,

View File

@@ -249,9 +249,9 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
try { try {
if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) { if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
// Fire-and-forget // Fire-and-forget
Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ }) Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* Non-critical: NTFY notification errors are ignored */ })
} }
} catch { /* ignore */ } } catch { /* Non-critical: Webhook buffer cleanup can fail safely */ }
// Console output with better formatting and contextual icons // Console output with better formatting and contextual icons
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓' const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'

View File

@@ -86,7 +86,7 @@ function normalizeConfig(raw: unknown): Config {
? true ? true
: (typeof browserConfig.headless === 'boolean' : (typeof browserConfig.headless === 'boolean'
? browserConfig.headless ? browserConfig.headless
: (typeof n.headless === 'boolean' ? n.headless : false)) // Legacy fallback : (typeof n.headless === 'boolean' ? n.headless : false)) // COMPATIBILITY: Flat headless field (pre-v2.50)
const globalTimeout = browserConfig.globalTimeout ?? n.globalTimeout ?? '30s' const globalTimeout = browserConfig.globalTimeout ?? n.globalTimeout ?? '30s'
const browser: ConfigBrowser = { const browser: ConfigBrowser = {
@@ -271,7 +271,7 @@ function buildSchedulingConfig(raw: unknown): ConfigScheduling | undefined {
scheduling.time = timeField scheduling.time = timeField
} }
// Priority 2: Legacy cron format (backwards compatibility) // Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58)
const cronRaw = source.cron const cronRaw = source.cron
if (cronRaw && typeof cronRaw === 'object') { if (cronRaw && typeof cronRaw === 'object') {
scheduling.cron = { scheduling.cron = {
@@ -315,7 +315,7 @@ export function loadAccounts(): Account[] {
path.join(process.cwd(), file + 'c'), // cwd/accounts.jsonc path.join(process.cwd(), file + 'c'), // cwd/accounts.jsonc
path.join(process.cwd(), 'src', file), // cwd/src/accounts.json path.join(process.cwd(), 'src', file), // cwd/src/accounts.json
path.join(process.cwd(), 'src', file + 'c'), // cwd/src/accounts.jsonc path.join(process.cwd(), 'src', file + 'c'), // cwd/src/accounts.jsonc
path.join(__dirname, file), // dist/accounts.json (legacy) path.join(__dirname, file), // dist/accounts.json (compiled output)
path.join(__dirname, file + 'c') // dist/accounts.jsonc path.join(__dirname, file + 'c') // dist/accounts.jsonc
] ]
let chosen: string | null = null let chosen: string | null = null