diff --git a/src/account-creation/AccountCreator.ts b/src/account-creation/AccountCreator.ts index b154887..1ee29a6 100644 --- a/src/account-creation/AccountCreator.ts +++ b/src/account-creation/AccountCreator.ts @@ -33,6 +33,18 @@ export class AccountCreator { 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 * Reserved for future use - simplifies selector fallback logic @@ -950,12 +962,16 @@ export class AccountCreator { await emailInput.fill(newEmail) 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 emailUsername = newEmail.split('@')[0] 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 } else { throw new Error('Email retry input value not verified') @@ -1024,12 +1040,16 @@ export class AccountCreator { await emailInput.fill(newEmail) 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 emailUsername = newEmail.split('@')[0] 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 } else { throw new Error('Email auto-retry input value not verified') diff --git a/src/dashboard/BotController.ts b/src/dashboard/BotController.ts index 5a2f0ee..251aeeb 100644 --- a/src/dashboard/BotController.ts +++ b/src/dashboard/BotController.ts @@ -6,6 +6,7 @@ import { dashboardState } from './state' export class BotController { private botInstance: MicrosoftRewardsBot | null = null private startTime?: Date + private isStarting: boolean = false // Race condition protection constructor() { process.on('exit', () => this.stop()) @@ -24,11 +25,17 @@ export class BotController { } public async start(): Promise<{ success: boolean; error?: string; pid?: number }> { + // FIXED: Race condition protection - prevent multiple simultaneous start() calls if (this.botInstance) { return { success: false, error: 'Bot is already running' } } + + if (this.isStarting) { + return { success: false, error: 'Bot is currently starting, please wait' } + } try { + this.isStarting = true this.log('🚀 Starting bot...', 'log') const { MicrosoftRewardsBot } = await import('../index') @@ -61,6 +68,8 @@ export class BotController { this.log(`Failed to start bot: ${errorMsg}`, 'error') this.cleanup() return { success: false, error: errorMsg } + } finally { + this.isStarting = false } } diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 1fa419c..c364a51 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -1,5 +1,6 @@ import { Page } from 'rebrowser-playwright' +import { TIMEOUTS } from '../constants' import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData' import { MicrosoftRewardsBot } from '../index' @@ -14,6 +15,14 @@ const ACTIVITY_SELECTORS = { byOfferId: (offerId: string) => `[data-bi-id^="${offerId}"] .pointLink:not(.contentContainer .pointLink)` } 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 { public bot: MicrosoftRewardsBot private jobState: JobState @@ -161,7 +170,7 @@ export class Workers { for (const activity of activities) { try { 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) 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') } - await this.applyThrottle(throttle, 1200, 2600) + await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX) } catch (error) { this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error') throttle.record(false) @@ -209,7 +218,13 @@ export class Workers { // Validate offerId exists before using it in selector 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) } @@ -217,9 +232,9 @@ export class Workers { } private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise { - 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.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 { @@ -227,14 +242,14 @@ export class Workers { // Check if element exists before clicking (avoid 30s timeout) try { - await page.waitForSelector(selector, { timeout: 5000 }) + await page.waitForSelector(selector, { timeout: TIMEOUTS.NETWORK_IDLE }) } catch (error) { 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 } // 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) // Execute activity with timeout protection using Promise.race diff --git a/src/interface/ActivityHandler.ts b/src/interface/ActivityHandler.ts index 215b55f..3350c35 100644 --- a/src/interface/ActivityHandler.ts +++ b/src/interface/ActivityHandler.ts @@ -1,10 +1,30 @@ -import type { MorePromotion, PromotionalItem } from './DashboardData' import type { Page } from 'playwright' +import type { MorePromotion, PromotionalItem } from './DashboardData' /** * 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 { /** Optional identifier used in logging output */ diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 1e8fb28..14ce23d 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -9,6 +9,24 @@ export function getErrorMessage(error: unknown): string { 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 * IMPROVED: Added comprehensive documentation