Improved error handling and added protection against race conditions in the bot controller. Extracted Microsoft domain verification for better maintainability. Added support for custom activity managers and improved documentation.

This commit is contained in:
2025-11-10 22:20:34 +01:00
parent e9a1e2dbcf
commit 84a4461a2f
5 changed files with 96 additions and 14 deletions

View File

@@ -33,6 +33,18 @@ export class AccountCreator {
await this.page.waitForTimeout(Math.floor(delay)) await this.page.waitForTimeout(Math.floor(delay))
} }
/**
* Helper: Check if email domain is a Microsoft-managed domain
* Extracted to avoid duplication and improve maintainability
*/
private isMicrosoftDomain(domain: string | undefined): boolean {
if (!domain) return false
const lowerDomain = domain.toLowerCase()
return lowerDomain === 'outlook.com' ||
lowerDomain === 'hotmail.com' ||
lowerDomain === 'outlook.fr'
}
/** /**
* UTILITY: Find first visible element from list of selectors * UTILITY: Find first visible element from list of selectors
* Reserved for future use - simplifies selector fallback logic * Reserved for future use - simplifies selector fallback logic
@@ -950,12 +962,16 @@ export class AccountCreator {
await emailInput.fill(newEmail) await emailInput.fill(newEmail)
await this.humanDelay(1200, 2500) await this.humanDelay(1200, 2500)
// SMART VERIFICATION: Microsoft may separate domain // SMART VERIFICATION: Microsoft may separate domain for managed email providers
const inputValue = await emailInput.inputValue().catch(() => '') const inputValue = await emailInput.inputValue().catch(() => '')
const emailUsername = newEmail.split('@')[0] const emailUsername = newEmail.split('@')[0]
const emailDomain = newEmail.split('@')[1] const emailDomain = newEmail.split('@')[1]
if (inputValue === newEmail || (inputValue === emailUsername && (emailDomain === 'outlook.com' || emailDomain === 'hotmail.com' || emailDomain === 'outlook.fr'))) { // Check if input matches full email OR username only (when domain is Microsoft-managed)
const isFullMatch = inputValue === newEmail
const isUsernameOnlyMatch = inputValue === emailUsername && this.isMicrosoftDomain(emailDomain)
if (isFullMatch || isUsernameOnlyMatch) {
return true return true
} else { } else {
throw new Error('Email retry input value not verified') throw new Error('Email retry input value not verified')
@@ -1024,12 +1040,16 @@ export class AccountCreator {
await emailInput.fill(newEmail) await emailInput.fill(newEmail)
await this.humanDelay(1200, 2500) await this.humanDelay(1200, 2500)
// SMART VERIFICATION: Microsoft may separate domain // SMART VERIFICATION: Microsoft may separate domain for managed email providers
const inputValue = await emailInput.inputValue().catch(() => '') const inputValue = await emailInput.inputValue().catch(() => '')
const emailUsername = newEmail.split('@')[0] const emailUsername = newEmail.split('@')[0]
const emailDomain = newEmail.split('@')[1] const emailDomain = newEmail.split('@')[1]
if (inputValue === newEmail || (inputValue === emailUsername && (emailDomain === 'outlook.com' || emailDomain === 'hotmail.com' || emailDomain === 'outlook.fr'))) { // Check if input matches full email OR username only (when domain is Microsoft-managed)
const isFullMatch = inputValue === newEmail
const isUsernameOnlyMatch = inputValue === emailUsername && this.isMicrosoftDomain(emailDomain)
if (isFullMatch || isUsernameOnlyMatch) {
return true return true
} else { } else {
throw new Error('Email auto-retry input value not verified') throw new Error('Email auto-retry input value not verified')

View File

@@ -6,6 +6,7 @@ import { dashboardState } from './state'
export class BotController { export class BotController {
private botInstance: MicrosoftRewardsBot | null = null private botInstance: MicrosoftRewardsBot | null = null
private startTime?: Date private startTime?: Date
private isStarting: boolean = false // Race condition protection
constructor() { constructor() {
process.on('exit', () => this.stop()) process.on('exit', () => this.stop())
@@ -24,11 +25,17 @@ export class BotController {
} }
public async start(): Promise<{ success: boolean; error?: string; pid?: number }> { public async start(): Promise<{ success: boolean; error?: string; pid?: number }> {
// FIXED: Race condition protection - prevent multiple simultaneous start() calls
if (this.botInstance) { if (this.botInstance) {
return { success: false, error: 'Bot is already running' } return { success: false, error: 'Bot is already running' }
} }
if (this.isStarting) {
return { success: false, error: 'Bot is currently starting, please wait' }
}
try { try {
this.isStarting = true
this.log('🚀 Starting bot...', 'log') this.log('🚀 Starting bot...', 'log')
const { MicrosoftRewardsBot } = await import('../index') const { MicrosoftRewardsBot } = await import('../index')
@@ -61,6 +68,8 @@ export class BotController {
this.log(`Failed to start bot: ${errorMsg}`, 'error') this.log(`Failed to start bot: ${errorMsg}`, 'error')
this.cleanup() this.cleanup()
return { success: false, error: errorMsg } return { success: false, error: errorMsg }
} finally {
this.isStarting = false
} }
} }

View File

@@ -1,5 +1,6 @@
import { Page } from 'rebrowser-playwright' import { Page } from 'rebrowser-playwright'
import { TIMEOUTS } from '../constants'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData' import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index' import { MicrosoftRewardsBot } from '../index'
@@ -14,6 +15,14 @@ const ACTIVITY_SELECTORS = {
byOfferId: (offerId: string) => `[data-bi-id^="${offerId}"] .pointLink:not(.contentContainer .pointLink)` byOfferId: (offerId: string) => `[data-bi-id^="${offerId}"] .pointLink:not(.contentContainer .pointLink)`
} as const } as const
// Activity processing delays (in milliseconds)
const ACTIVITY_DELAYS = {
THROTTLE_MIN: 800,
THROTTLE_MAX: 1400,
ACTIVITY_SPACING_MIN: 1200,
ACTIVITY_SPACING_MAX: 2600
} as const
export class Workers { export class Workers {
public bot: MicrosoftRewardsBot public bot: MicrosoftRewardsBot
private jobState: JobState private jobState: JobState
@@ -161,7 +170,7 @@ export class Workers {
for (const activity of activities) { for (const activity of activities) {
try { try {
activityPage = await this.manageTabLifecycle(activityPage, activityInitial) activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
await this.applyThrottle(throttle, 800, 1400) await this.applyThrottle(throttle, ACTIVITY_DELAYS.THROTTLE_MIN, ACTIVITY_DELAYS.THROTTLE_MAX)
const selector = await this.buildActivitySelector(activityPage, activity, punchCard) const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
await this.prepareActivityPage(activityPage, selector, throttle) await this.prepareActivityPage(activityPage, selector, throttle)
@@ -173,7 +182,7 @@ export class Workers {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn') this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
} }
await this.applyThrottle(throttle, 1200, 2600) await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX)
} catch (error) { } catch (error) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error') this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
throttle.record(false) throttle.record(false)
@@ -209,7 +218,13 @@ export class Workers {
// Validate offerId exists before using it in selector // Validate offerId exists before using it in selector
if (!activity.offerId) { if (!activity.offerId) {
this.bot.log(this.bot.isMobile, 'WORKERS', `Activity "${activity.name || activity.title}" has no offerId, falling back to name-based selector`, 'warn') // IMPROVED: More prominent logging for data integrity issue
this.bot.log(
this.bot.isMobile,
'WORKERS',
`⚠️ DATA INTEGRITY: Activity "${activity.name || activity.title}" is missing offerId field. This may indicate a dashboard API change or data corruption. Falling back to name-based selector.`,
'warn'
)
return ACTIVITY_SELECTORS.byName(activity.name) return ACTIVITY_SELECTORS.byName(activity.name)
} }
@@ -217,9 +232,9 @@ export class Workers {
} }
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> { private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile)) await page.waitForLoadState('networkidle', { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile))
await this.bot.browser.utils.humanizePage(page) await this.bot.browser.utils.humanizePage(page)
await this.applyThrottle(throttle, 1200, 2600) await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX)
} }
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> { private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
@@ -227,14 +242,14 @@ export class Workers {
// Check if element exists before clicking (avoid 30s timeout) // Check if element exists before clicking (avoid 30s timeout)
try { try {
await page.waitForSelector(selector, { timeout: 5000 }) await page.waitForSelector(selector, { timeout: TIMEOUTS.NETWORK_IDLE })
} catch (error) { } catch (error) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity selector not found (might be completed or unavailable): ${selector}`, 'warn') this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity selector not found (might be completed or unavailable): ${selector}`, 'warn')
return // Skip this activity gracefully instead of waiting 30s return // Skip this activity gracefully instead of waiting 30s
} }
// Click with timeout to prevent indefinite hangs // Click with timeout to prevent indefinite hangs
await page.click(selector, { timeout: 10000 }) await page.click(selector, { timeout: TIMEOUTS.DASHBOARD_WAIT })
page = await this.bot.browser.utils.getLatestTab(page) page = await this.bot.browser.utils.getLatestTab(page)
// Execute activity with timeout protection using Promise.race // Execute activity with timeout protection using Promise.race

View File

@@ -1,10 +1,30 @@
import type { MorePromotion, PromotionalItem } from './DashboardData'
import type { Page } from 'playwright' import type { Page } from 'playwright'
import type { MorePromotion, PromotionalItem } from './DashboardData'
/** /**
* Activity handler contract for solving a single dashboard activity. * Activity handler contract for solving a single dashboard activity.
* Implementations should be stateless (or hold only a reference to the bot) *
* and perform all required steps on the provided page. * **Extensibility Pattern**: This interface allows developers to register custom activity handlers
* for new or unsupported activity types without modifying the core Activities.ts dispatcher.
*
* **Usage Example**:
* ```typescript
* class MyCustomHandler implements ActivityHandler {
* id = 'my-custom-handler'
* canHandle(activity) { return activity.name === 'special-promo' }
* async run(page, activity) {
* // Custom logic here
* }
* }
*
* // In bot initialization:
* bot.activities.registerHandler(new MyCustomHandler())
* ```
*
* **Notes**:
* - Implementations should be stateless (or hold only a reference to the bot)
* - The page is already navigated to the activity tab/window by the caller
* - Custom handlers are checked BEFORE built-in handlers for maximum flexibility
*/ */
export interface ActivityHandler { export interface ActivityHandler {
/** Optional identifier used in logging output */ /** Optional identifier used in logging output */

View File

@@ -9,6 +9,24 @@ export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
/**
* Format a standardized error message for logging
* Ensures consistent error message formatting across all modules
*
* @param context - Context string (e.g., 'SEARCH-BING', 'LOGIN')
* @param error - Error object or unknown value
* @param prefix - Optional custom prefix (defaults to 'Error')
* @returns Formatted error message
*
* @example
* formatErrorMessage('SEARCH', err) // 'Error in SEARCH: Network timeout'
* formatErrorMessage('LOGIN', err, 'Failed') // 'Failed in LOGIN: Invalid credentials'
*/
export function formatErrorMessage(context: string, error: unknown, prefix: string = 'Error'): string {
const errorMsg = getErrorMessage(error)
return `${prefix} in ${context}: ${errorMsg}`
}
/** /**
* Utility class for common operations * Utility class for common operations
* IMPROVED: Added comprehensive documentation * IMPROVED: Added comprehensive documentation