From 89bc226d6b60fac0ede4c5006266e161caa7a00a Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Tue, 11 Nov 2025 12:59:42 +0100 Subject: [PATCH] New structure --- .dockerignore | 12 +- .github/copilot-instructions.md | 57 +- Dockerfile => docker/Dockerfile | 9 +- compose.yaml => docker/compose.yaml | 0 {src => docker}/crontab.template | 2 +- entrypoint.sh => docker/entrypoint.sh | 2 +- {src => docker}/run_daily.sh | 0 scripts/README.md | 22 + run.sh => scripts/run.sh | 0 flake.lock => setup/nix/flake.lock | 0 flake.nix => setup/nix/flake.nix | 0 src/account-creation/AccountCreator.ts | 790 +++++++++--------- src/account-creation/cli.ts | 46 +- src/browser/Browser.ts | 22 +- src/browser/BrowserFunc.ts | 56 +- src/browser/BrowserUtil.ts | 16 +- src/constants.ts | 4 +- src/dashboard/BotController.ts | 22 +- src/dashboard/routes.ts | 32 +- src/dashboard/server.ts | 28 +- src/flows/DesktopFlow.ts | 2 +- src/flows/FlowUtils.ts | 12 +- src/flows/MobileFlow.ts | 4 +- src/flows/SummaryReporter.ts | 24 +- src/functions/Login.ts | 466 +++++------ src/functions/Workers.ts | 32 +- src/index.ts | 24 +- src/util/{ => browser}/BrowserFactory.ts | 6 +- src/util/{ => browser}/Humanizer.ts | 8 +- src/util/{ => browser}/UserAgent.ts | 52 +- src/util/{ => core}/Retry.ts | 4 +- src/util/{ => core}/Utils.ts | 0 src/util/{ => network}/Axios.ts | 32 +- .../{ => network}/QueryDiversityEngine.ts | 26 +- .../{ => notifications}/AdaptiveThrottler.ts | 0 .../{ => notifications}/ConclusionWebhook.ts | 6 +- .../ErrorReportingWebhook.ts | 16 +- src/util/{ => notifications}/Logger.ts | 4 +- src/util/{ => notifications}/Ntfy.ts | 2 +- src/util/{ => security}/Totp.ts | 0 src/util/{ => state}/JobState.ts | 2 +- src/util/{ => state}/Load.ts | 44 +- src/util/{ => state}/MobileRetryTracker.ts | 0 src/util/{ => validation}/BanDetector.ts | 0 .../{ => validation}/LoginStateDetector.ts | 0 src/util/{ => validation}/StartupValidator.ts | 48 +- 46 files changed, 990 insertions(+), 944 deletions(-) rename Dockerfile => docker/Dockerfile (89%) rename compose.yaml => docker/compose.yaml (100%) rename {src => docker}/crontab.template (77%) rename entrypoint.sh => docker/entrypoint.sh (97%) rename {src => docker}/run_daily.sh (100%) create mode 100644 scripts/README.md rename run.sh => scripts/run.sh (100%) rename flake.lock => setup/nix/flake.lock (100%) rename flake.nix => setup/nix/flake.nix (100%) rename src/util/{ => browser}/BrowserFactory.ts (91%) rename src/util/{ => browser}/Humanizer.ts (92%) rename src/util/{ => browser}/UserAgent.ts (95%) rename src/util/{ => core}/Retry.ts (97%) rename src/util/{ => core}/Utils.ts (100%) rename src/util/{ => network}/Axios.ts (92%) rename src/util/{ => network}/QueryDiversityEngine.ts (97%) rename src/util/{ => notifications}/AdaptiveThrottler.ts (100%) rename src/util/{ => notifications}/ConclusionWebhook.ts (97%) rename src/util/{ => notifications}/ErrorReportingWebhook.ts (98%) rename src/util/{ => notifications}/Logger.ts (99%) rename src/util/{ => notifications}/Ntfy.ts (96%) rename src/util/{ => security}/Totp.ts (100%) rename src/util/{ => state}/JobState.ts (98%) rename src/util/{ => state}/Load.ts (97%) rename src/util/{ => state}/MobileRetryTracker.ts (100%) rename src/util/{ => validation}/BanDetector.ts (100%) rename src/util/{ => validation}/LoginStateDetector.ts (100%) rename src/util/{ => validation}/StartupValidator.ts (96%) diff --git a/.dockerignore b/.dockerignore index 8187574..f199b50 100644 --- a/.dockerignore +++ b/.dockerignore @@ -52,16 +52,12 @@ browser/ .eslintcache setup/ -# Docker files (no recursion) -Dockerfile -docker-compose.yml -compose.yaml +# Docker files (organized in docker/ folder - no recursion needed) +docker/ .dockerignore -# NixOS specific files (not needed in Docker) -flake.nix -flake.lock -run.sh +# Scripts (organized in scripts/ folder - not needed in Docker) +scripts/ # Asset files (not needed for runtime) assets/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3dfa68e..dea1619 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,17 +44,33 @@ src/ │ ├── Poll.ts # Poll completion │ ├── ThisOrThat.ts # This or That game │ └── ... -├── util/ # Shared utilities + infrastructure -│ ├── Axios.ts # HTTP client with proxy support -│ ├── BrowserFactory.ts # Centralized browser creation -│ ├── Humanizer.ts # Random delays, mouse gestures -│ ├── BanDetector.ts # Heuristic ban detection -│ ├── QueryDiversityEngine.ts # Multi-source search query generation -│ ├── JobState.ts # Persistent job state tracking -│ ├── Logger.ts # Centralized logging with redaction -│ ├── Retry.ts # Exponential backoff retry logic -│ ├── Utils.ts # General-purpose helpers -│ └── ... +├── util/ # Shared utilities (ORGANIZED BY CATEGORY) +│ ├── core/ # Core utilities +│ │ ├── Utils.ts # General-purpose helpers +│ │ └── Retry.ts # Exponential backoff retry logic +│ ├── network/ # HTTP & API utilities +│ │ ├── Axios.ts # HTTP client with proxy support +│ │ └── QueryDiversityEngine.ts # Multi-source search query generation +│ ├── browser/ # Browser automation utilities +│ │ ├── BrowserFactory.ts # Centralized browser creation +│ │ ├── Humanizer.ts # Random delays, mouse gestures +│ │ └── UserAgent.ts # User agent generation +│ ├── state/ # State & persistence +│ │ ├── JobState.ts # Persistent job state tracking +│ │ ├── Load.ts # Configuration & session loading +│ │ └── MobileRetryTracker.ts # Mobile search retry tracking +│ ├── validation/ # Validation & detection +│ │ ├── StartupValidator.ts # Comprehensive startup validation +│ │ ├── BanDetector.ts # Heuristic ban detection +│ │ └── LoginStateDetector.ts # Login state detection +│ ├── security/ # Authentication & security +│ │ └── Totp.ts # TOTP generation for 2FA +│ └── notifications/ # Logging & notifications +│ ├── Logger.ts # Centralized logging with redaction +│ ├── ConclusionWebhook.ts # Summary webhook notifications +│ ├── ErrorReportingWebhook.ts # Error reporting +│ ├── Ntfy.ts # Push notifications +│ └── AdaptiveThrottler.ts # Adaptive delay management ├── dashboard/ # Real-time web dashboard (Express + WebSocket) │ ├── server.ts # Express server + routes │ ├── routes.ts # API endpoints @@ -73,9 +89,20 @@ src/ ├── nameDatabase.ts # First/last name pool ├── types.ts # Account creation interfaces └── README.md # Account creation guide +docker/ # Docker deployment files +├── Dockerfile # Multi-stage Docker build +├── compose.yaml # Docker Compose configuration +├── entrypoint.sh # Container initialization script +├── run_daily.sh # Daily execution wrapper (cron) +└── crontab.template # Cron schedule template +scripts/ # Utility scripts +└── run.sh # Nix development environment launcher setup/ ├── setup.bat # Windows setup script ├── setup.sh # Linux/Mac setup script +├── nix/ # NixOS configuration +│ ├── flake.nix # Nix flake definition +│ └── flake.lock # Nix flake lock file └── update/ ├── setup.mjs # Initial setup automation └── update.mjs # GitHub ZIP-based auto-updater (NO GIT REQUIRED!) @@ -986,8 +1013,8 @@ private combinedDeduplication(queries: string[], threshold = 0.65): string[] { ### Docker & Scheduling Context -**entrypoint.sh:** -- **Purpose:** Docker container initialization script +**docker/entrypoint.sh:** +- **Purpose:** Docker container initialization script (located in `docker/` directory) - **Key Features:** - Timezone configuration (env: `TZ`, default UTC) - Initial run on start (env: `RUN_ON_START=true`) @@ -995,8 +1022,8 @@ private combinedDeduplication(queries: string[], threshold = 0.65): string[] { - Playwright browser preinstallation (`PLAYWRIGHT_BROWSERS_PATH=0`) - **Usage:** Docker Compose sets `CRON_SCHEDULE`, container runs cron in foreground -**run_daily.sh:** -- **Purpose:** Daily execution wrapper for cron jobs +**docker/run_daily.sh:** +- **Purpose:** Daily execution wrapper for cron jobs (located in `docker/` directory) - **Key Features:** - Random sleep delay (0-30min) to avoid simultaneous runs across containers - Environment variable: `SKIP_RANDOM_SLEEP=true` to disable delay diff --git a/Dockerfile b/docker/Dockerfile similarity index 89% rename from Dockerfile rename to docker/Dockerfile index e9ff066..355549c 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -80,11 +80,12 @@ COPY --from=builder /usr/src/microsoft-rewards-bot/package*.json ./ COPY --from=builder /usr/src/microsoft-rewards-bot/node_modules ./node_modules # Copy runtime scripts with proper permissions and normalize line endings for non-Unix users -COPY --chmod=755 src/run_daily.sh ./src/run_daily.sh -COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template -COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh +# IMPROVED: Scripts now organized in docker/ folder +COPY --chmod=755 docker/run_daily.sh ./docker/run_daily.sh +COPY --chmod=644 docker/crontab.template /etc/cron.d/microsoft-rewards-cron.template +COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \ - && sed -i 's/\r$//' ./src/run_daily.sh + && sed -i 's/\r$//' ./docker/run_daily.sh # Entrypoint handles TZ, initial run toggle, cron templating & launch ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/compose.yaml b/docker/compose.yaml similarity index 100% rename from compose.yaml rename to docker/compose.yaml diff --git a/src/crontab.template b/docker/crontab.template similarity index 77% rename from src/crontab.template rename to docker/crontab.template index 5fcd77a..6eb3b73 100644 --- a/src/crontab.template +++ b/docker/crontab.template @@ -1,3 +1,3 @@ # Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs -${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-bot/src/run_daily.sh >> /proc/1/fd/1 2>&1 +${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-bot/docker/run_daily.sh >> /proc/1/fd/1 2>&1 diff --git a/entrypoint.sh b/docker/entrypoint.sh similarity index 97% rename from entrypoint.sh rename to docker/entrypoint.sh index 269d7ae..2ed2656 100644 --- a/entrypoint.sh +++ b/docker/entrypoint.sh @@ -26,7 +26,7 @@ if [ "${RUN_ON_START:-false}" = "true" ]; then exit 1 } # Skip random sleep for initial run, but preserve setting for cron jobs - SKIP_RANDOM_SLEEP=true src/run_daily.sh + SKIP_RANDOM_SLEEP=true docker/run_daily.sh echo "[entrypoint-bg] Initial run completed at $(date)" ) & echo "[entrypoint] Background process started (PID: $!)" diff --git a/src/run_daily.sh b/docker/run_daily.sh similarity index 100% rename from src/run_daily.sh rename to docker/run_daily.sh diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..f17b427 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,22 @@ +# Scripts Directory + +This directory contains utility scripts for development and deployment. + +## Available Scripts + +### `run.sh` +**Purpose:** Nix development environment launcher +**Usage:** `./run.sh` +**Description:** Launches the bot using Nix develop environment with xvfb-run for headless browser support. + +**Requirements:** +- Nix package manager +- xvfb (X Virtual Framebuffer) + +**Environment:** +This script is designed for NixOS or systems with Nix installed. It provides a reproducible development environment as defined in `setup/nix/flake.nix`. + +--- + +For Docker deployment, see the `docker/` directory. +For setup scripts, see the `setup/` directory. diff --git a/run.sh b/scripts/run.sh similarity index 100% rename from run.sh rename to scripts/run.sh diff --git a/flake.lock b/setup/nix/flake.lock similarity index 100% rename from flake.lock rename to setup/nix/flake.lock diff --git a/flake.nix b/setup/nix/flake.nix similarity index 100% rename from flake.nix rename to setup/nix/flake.nix diff --git a/src/account-creation/AccountCreator.ts b/src/account-creation/AccountCreator.ts index 1ee29a6..8650792 100644 --- a/src/account-creation/AccountCreator.ts +++ b/src/account-creation/AccountCreator.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import * as readline from 'readline' import type { BrowserContext, Page } from 'rebrowser-playwright' -import { log } from '../util/Logger' +import { log } from '../util/notifications/Logger' import { DataGenerator } from './DataGenerator' import { CreatedAccount } from './types' @@ -40,9 +40,9 @@ export class AccountCreator { private isMicrosoftDomain(domain: string | undefined): boolean { if (!domain) return false const lowerDomain = domain.toLowerCase() - return lowerDomain === 'outlook.com' || - lowerDomain === 'hotmail.com' || - lowerDomain === 'outlook.fr' + return lowerDomain === 'outlook.com' || + lowerDomain === 'hotmail.com' || + lowerDomain === 'outlook.fr' } /** @@ -96,7 +96,7 @@ export class AccountCreator { } } } - + return null } @@ -105,9 +105,9 @@ export class AccountCreator { */ private async waitForDropdownClosed(context: string, maxWaitMs: number = 5000): Promise { log(false, 'CREATOR', `[${context}] Waiting for dropdown to close...`, 'log', 'cyan') - + const startTime = Date.now() - + while (Date.now() - startTime < maxWaitMs) { // Check if any dropdown menu is visible const dropdownSelectors = [ @@ -117,7 +117,7 @@ export class AccountCreator { 'ul[role="menu"]', '[class*="dropdown"][class*="open"]' ] - + let anyVisible = false for (const selector of dropdownSelectors) { const visible = await this.page.locator(selector).first().isVisible().catch(() => false) @@ -126,14 +126,14 @@ export class AccountCreator { break } } - + if (!anyVisible) { return true } - + await this.page.waitForTimeout(500) } - + return false } @@ -168,12 +168,12 @@ export class AccountCreator { '.error-message', '[data-bind*="errorMessage"]' ] - + for (const selector of errorSelectors) { try { const errorElement = this.page.locator(selector).first() const isVisible = await errorElement.isVisible().catch(() => false) - + if (isVisible) { const errorText = await errorElement.textContent().catch(() => 'Unknown error') log(false, 'CREATOR', `Error detected: ${errorText}`, 'error') @@ -183,7 +183,7 @@ export class AccountCreator { continue } } - + return true } @@ -354,20 +354,20 @@ export class AccountCreator { */ private async waitForPageStable(context: string, maxWaitMs: number = 15000): Promise { // REDUCED: Don't log start - too verbose - + const startTime = Date.now() - + try { // STEP 1: Wait for network to be idle await this.page.waitForLoadState('networkidle', { timeout: Math.min(maxWaitMs, 10000) }) - + // STEP 2: Wait for DOM to be fully loaded // Silent catch justified: DOMContentLoaded may already be complete - await this.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {}) - + await this.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => { }) + // STEP 3: REDUCED delay - pages load fast await this.humanDelay(1500, 2500) - + // STEP 4: Check for loading indicators const loadingSelectors = [ '.loading', @@ -375,20 +375,20 @@ export class AccountCreator { '[class*="loading"]', '[aria-busy="true"]' ] - + // Wait for loading indicators to disappear for (const selector of loadingSelectors) { const element = this.page.locator(selector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { // Silent catch justified: Loading indicators may disappear before timeout, which is fine - await element.waitFor({ state: 'hidden', timeout: Math.min(5000, maxWaitMs - (Date.now() - startTime)) }).catch(() => {}) + await element.waitFor({ state: 'hidden', timeout: Math.min(5000, maxWaitMs - (Date.now() - startTime)) }).catch(() => { }) } } - + return true - + } catch (error) { // Only log actual failures, not warnings const msg = error instanceof Error ? error.message : String(error) @@ -407,7 +407,7 @@ export class AccountCreator { private async waitForAccountCreation(): Promise { const maxWaitTime = 60000 // 60 seconds const startTime = Date.now() - + try { // STEP 1: Wait for any "Creating account" messages to appear AND disappear const creationMessages = [ @@ -418,11 +418,11 @@ export class AccountCreator { 'text="Please wait"', 'text="Veuillez patienter"' ] - + for (const messageSelector of creationMessages) { const element = this.page.locator(messageSelector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { // Wait for this message to disappear try { @@ -432,19 +432,19 @@ export class AccountCreator { } } } - + // STEP 2: Wait for URL to stabilize or change to expected page let urlStableCount = 0 let lastUrl = this.page.url() - + while (Date.now() - startTime < maxWaitTime) { await this.humanDelay(1000, 1500) - + const currentUrl = this.page.url() - + if (currentUrl === lastUrl) { urlStableCount++ - + // URL has been stable for 3 consecutive checks if (urlStableCount >= 3) { break @@ -454,15 +454,15 @@ export class AccountCreator { urlStableCount = 0 } } - + // STEP 3: Wait for page to be fully loaded await this.waitForPageStable('ACCOUNT_CREATION', 15000) - + // STEP 4: Additional safety delay await this.humanDelay(3000, 5000) - + return true - + } catch (error) { return false } @@ -472,28 +472,28 @@ export class AccountCreator { * CRITICAL: Verify that an element exists, is visible, and is interactable */ private async verifyElementReady( - selector: string, - context: string, + selector: string, + context: string, timeoutMs: number = 10000 ): Promise { try { const element = this.page.locator(selector).first() - + // Wait for element to exist await element.waitFor({ timeout: timeoutMs, state: 'attached' }) - + // Wait for element to be visible await element.waitFor({ timeout: 5000, state: 'visible' }) - + // Check if element is enabled (for buttons/inputs) const isEnabled = await element.isEnabled().catch(() => true) - + if (!isEnabled) { return false } - + return true - + } catch (error) { return false } @@ -571,9 +571,9 @@ export class AccountCreator { if (captchaDetected) { log(false, 'CREATOR', '⚠️ CAPTCHA detected - waiting for human to solve it...', 'warn', 'yellow') log(false, 'CREATOR', 'Please solve the CAPTCHA in the browser. The script will wait...', 'log', 'yellow') - + await this.waitForCaptchaSolved() - + log(false, 'CREATOR', '✅ CAPTCHA solved! Continuing...', 'log', 'green') } @@ -587,7 +587,7 @@ export class AccountCreator { let recoveryEmailUsed: string | undefined let totpSecret: string | undefined let recoveryCode: string | undefined - + try { // Setup recovery email // Logic: If -r provided, use it. If -y (auto-accept), ask for it. Otherwise, interactive prompt. @@ -605,7 +605,7 @@ export class AccountCreator { const emailResult = await this.setupRecoveryEmail() if (emailResult) recoveryEmailUsed = emailResult } - + // Setup 2FA // Logic: If -y (auto-accept), enable it automatically. Otherwise, ask user. if (this.autoAccept) { @@ -669,7 +669,7 @@ export class AccountCreator { this.rl.close() this.rlClosed = true } - } catch {/* ignore */} + } catch {/* ignore */ } } } @@ -677,32 +677,32 @@ export class AccountCreator { if (this.referralUrl) { log(false, 'CREATOR', '🔗 Navigating to referral link...', 'log', 'cyan') await this.page.goto(this.referralUrl, { waitUntil: 'networkidle', timeout: 60000 }) - + await this.waitForPageStable('REFERRAL_PAGE', 10000) await this.humanDelay(1000, 2000) - + const joinButtonSelectors = [ 'a#start-earning-rewards-link', 'a.cta.learn-more-btn', 'a[href*="signup"]', 'button[class*="join"]' ] - + let clickSuccess = false for (const selector of joinButtonSelectors) { const button = this.page.locator(selector).first() const visible = await button.isVisible().catch(() => false) - + if (visible) { const urlBefore = this.page.url() - + await button.click() // OPTIMIZED: Reduced delay after Join click await this.humanDelay(1000, 1500) - + // CRITICAL: Verify the click actually did something const urlAfter = this.page.url() - + if (urlAfter !== urlBefore || urlAfter.includes('login.live.com') || urlAfter.includes('signup')) { // OPTIMIZED: Reduced from 8000ms to 3000ms await this.waitForPageStable('AFTER_JOIN_CLICK', 3000) @@ -714,7 +714,7 @@ export class AccountCreator { // Try clicking again await button.click() await this.humanDelay(1000, 1500) - + const urlRetry = this.page.url() if (urlRetry !== urlBefore) { // OPTIMIZED: Reduced from 8000ms to 3000ms @@ -725,7 +725,7 @@ export class AccountCreator { } } } - + if (!clickSuccess) { // Navigate directly to signup await this.page.goto('https://login.live.com/', { waitUntil: 'networkidle', timeout: 30000 }) @@ -735,7 +735,7 @@ export class AccountCreator { } else { log(false, 'CREATOR', '🌐 Navigating to Microsoft login...', 'log', 'cyan') await this.page.goto('https://login.live.com/', { waitUntil: 'networkidle', timeout: 60000 }) - + // OPTIMIZED: Reduced from 20000ms to 5000ms await this.waitForPageStable('LOGIN_PAGE', 5000) await this.humanDelay(1000, 1500) @@ -745,7 +745,7 @@ export class AccountCreator { private async clickCreateAccount(): Promise { // OPTIMIZED: Page is already stable from navigateToSignup(), no need to wait again // await this.waitForPageStable('BEFORE_CREATE_ACCOUNT', 3000) // REMOVED - + const createAccountSelectors = [ 'a[id*="signup"]', 'a[href*="signup"]', @@ -753,24 +753,24 @@ export class AccountCreator { 'button[id*="signup"]', 'a[data-testid*="signup"]' ] - + for (const selector of createAccountSelectors) { const button = this.page.locator(selector).first() - + try { // OPTIMIZED: Reduced timeout from 5000ms to 2000ms await button.waitFor({ timeout: 2000 }) - + const urlBefore = this.page.url() await button.click() - + // OPTIMIZED: Reduced delay from 1500-2500ms to 500-1000ms (click is instant) await this.humanDelay(500, 1000) - + // CRITICAL: Verify click worked const urlAfter = this.page.url() const emailFieldAppeared = await this.page.locator('input[type="email"]').first().isVisible().catch(() => false) - + if (urlAfter !== urlBefore || emailFieldAppeared) { // OPTIMIZED: Reduced from 3000ms to 1000ms - email field is already visible await this.humanDelay(1000, 1500) @@ -783,18 +783,18 @@ export class AccountCreator { continue } } - + throw new Error('Could not find working "Create account" button') } private async generateAndFillEmail(autoAccept = false): Promise { log(false, 'CREATOR', '📧 Configuring email...', 'log', 'cyan') - + // OPTIMIZED: Page is already stable from clickCreateAccount(), minimal wait needed await this.humanDelay(500, 1000) - + let email: string - + if (autoAccept) { // Auto mode: generate automatically email = this.dataGenerator.generateEmail() @@ -802,7 +802,7 @@ export class AccountCreator { } else { // Interactive mode: ask user const useAutoGenerate = await this.askQuestion('Generate email automatically? (Y/n): ') - + if (useAutoGenerate.toLowerCase() === 'n' || useAutoGenerate.toLowerCase() === 'no') { email = await this.askQuestion('Enter your email: ') log(false, 'CREATOR', `Using custom email: ${email}`, 'log', 'cyan') @@ -814,7 +814,7 @@ export class AccountCreator { const emailInput = this.page.locator('input[type="email"]').first() await emailInput.waitFor({ timeout: 15000 }) - + // CRITICAL: Retry fill with SMART verification // Microsoft separates username from domain for outlook.com/hotmail.com addresses const emailFillSuccess = await this.retryOperation( @@ -823,12 +823,12 @@ export class AccountCreator { await this.humanDelay(800, 1500) await emailInput.fill(email) await this.humanDelay(1200, 2500) - + // SMART VERIFICATION: Check if Microsoft separated the domain const inputValue = await emailInput.inputValue().catch(() => '') const emailUsername = email.split('@')[0] // e.g., "sharon_jackson" const emailDomain = email.split('@')[1] // e.g., "outlook.com" - + // Check if input contains full email OR just username (Microsoft separated domain) if (inputValue === email) { // Full email is in input (not separated) @@ -848,33 +848,33 @@ export class AccountCreator { 3, 1000 ) - + if (!emailFillSuccess) { log(false, 'CREATOR', 'Failed to fill email after retries', 'error') return null } - + log(false, 'CREATOR', 'Clicking Next button...', 'log') const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() await nextBtn.waitFor({ timeout: 10000 }) - + // CRITICAL: Get current URL before clicking const urlBeforeClick = this.page.url() - + await nextBtn.click() // OPTIMIZED: Reduced delay after clicking Next await this.humanDelay(1000, 1500) await this.waitForPageStable('AFTER_EMAIL_SUBMIT', 10000) - + // CRITICAL: Verify the click had an effect const urlAfterClick = this.page.url() - + if (urlBeforeClick === urlAfterClick) { // URL didn't change - check if there's an error or if we're on password page const onPasswordPage = await this.page.locator('input[type="password"]').first().isVisible().catch(() => false) const hasError = await this.page.locator('div[id*="Error"], div[role="alert"]').first().isVisible().catch(() => false) - + if (!onPasswordPage && !hasError) { log(false, 'CREATOR', '⚠️ Email submission may have failed - no password field, no error', 'warn', 'yellow') log(false, 'CREATOR', 'Waiting longer for response...', 'log', 'cyan') @@ -883,77 +883,77 @@ export class AccountCreator { } else { log(false, 'CREATOR', `✅ URL changed: ${urlBeforeClick} → ${urlAfterClick}`, 'log', 'green') } - + const result = await this.handleEmailErrors(email) if (!result.success) { return null } - + // CRITICAL: If email was accepted by handleEmailErrors, trust that result // Don't do additional error check here as it may detect false positives // (e.g., transient errors that were already handled) log(false, 'CREATOR', `✅ Email step completed successfully: ${result.email}`, 'log', 'green') - + return result.email } private async handleEmailErrors(originalEmail: string, retryCount = 0): Promise<{ success: boolean; email: string | null }> { await this.humanDelay(1000, 1500) - + // CRITICAL: Prevent infinite retry loops const MAX_EMAIL_RETRIES = 5 if (retryCount >= MAX_EMAIL_RETRIES) { log(false, 'CREATOR', `❌ Max email retries (${MAX_EMAIL_RETRIES}) reached - giving up`, 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } - + const errorLocator = this.page.locator('div[id*="Error"], div[role="alert"]').first() const errorVisible = await errorLocator.isVisible().catch(() => false) - + if (!errorVisible) { log(false, 'CREATOR', `✅ Email accepted: ${originalEmail}`, 'log', 'green') return { success: true, email: originalEmail } } - + const errorText = await errorLocator.textContent().catch(() => '') || '' - + // IGNORE password requirements messages (not actual errors) if (errorText && (errorText.toLowerCase().includes('password') && errorText.toLowerCase().includes('characters'))) { // This is just password requirements info, not an error return { success: true, email: originalEmail } } - + log(false, 'CREATOR', `Email error: ${errorText} (attempt ${retryCount + 1}/${MAX_EMAIL_RETRIES})`, 'warn', 'yellow') - + // Check for reserved domain error if (errorText && (errorText.toLowerCase().includes('reserved') || errorText.toLowerCase().includes('réservé'))) { return await this.handleReservedDomain(originalEmail, retryCount) } - + // Check for email taken error - if (errorText && (errorText.toLowerCase().includes('taken') || errorText.toLowerCase().includes('pris') || - errorText.toLowerCase().includes('already') || errorText.toLowerCase().includes('déjà'))) { + if (errorText && (errorText.toLowerCase().includes('taken') || errorText.toLowerCase().includes('pris') || + errorText.toLowerCase().includes('already') || errorText.toLowerCase().includes('déjà'))) { return await this.handleEmailTaken(retryCount) } - + log(false, 'CREATOR', 'Unknown error type, pausing for inspection', 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } private async handleReservedDomain(originalEmail: string, retryCount = 0): Promise<{ success: boolean; email: string | null }> { log(false, 'CREATOR', `Domain blocked: ${originalEmail.split('@')[1]}`, 'warn', 'yellow') - + const username = originalEmail.split('@')[0] const newEmail = `${username}@outlook.com` - + log(false, 'CREATOR', `Retrying with: ${newEmail}`, 'log', 'cyan') - + const emailInput = this.page.locator('input[type="email"]').first() - + // CRITICAL: Retry fill with SMART verification (handles domain separation) const retryFillSuccess = await this.retryOperation( async () => { @@ -961,16 +961,16 @@ export class AccountCreator { await this.humanDelay(800, 1500) await emailInput.fill(newEmail) await this.humanDelay(1200, 2500) - + // 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] - + // 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 { @@ -981,26 +981,26 @@ export class AccountCreator { 3, 1000 ) - + if (!retryFillSuccess) { log(false, 'CREATOR', 'Failed to fill retry email', 'error') return { success: false, email: null } } - + const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() await nextBtn.click() await this.humanDelay(2000, 3000) await this.waitForPageStable('RETRY_EMAIL', 15000) - + return await this.handleEmailErrors(newEmail, retryCount + 1) } private async handleEmailTaken(retryCount = 0): Promise<{ success: boolean; email: string | null }> { log(false, 'CREATOR', 'Email taken, looking for Microsoft suggestions...', 'log', 'yellow') - + await this.humanDelay(2000, 3000) await this.waitForPageStable('EMAIL_SUGGESTIONS', 10000) - + // Multiple selectors for suggestions container const suggestionSelectors = [ 'div[data-testid="suggestions"]', @@ -1009,7 +1009,7 @@ export class AccountCreator { 'div[class*="suggestions"]', 'div[class*="TagGroup"]' ] - + let suggestionsContainer = null for (const selector of suggestionSelectors) { const container = this.page.locator(selector).first() @@ -1020,35 +1020,35 @@ export class AccountCreator { break } } - + if (!suggestionsContainer) { log(false, 'CREATOR', 'No suggestions found from Microsoft', 'warn', 'yellow') - + // CRITICAL FIX: Generate a new email automatically instead of freezing log(false, 'CREATOR', '🔄 Generating a new email automatically...', 'log', 'cyan') - + const newEmail = this.dataGenerator.generateEmail() log(false, 'CREATOR', `Generated new email: ${newEmail}`, 'log', 'cyan') - + // Clear and fill the email input with the new email const emailInput = this.page.locator('input[type="email"]').first() - + const retryFillSuccess = await this.retryOperation( async () => { await emailInput.clear() await this.humanDelay(800, 1500) await emailInput.fill(newEmail) await this.humanDelay(1200, 2500) - + // 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] - + // 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 { @@ -1059,94 +1059,94 @@ export class AccountCreator { 3, 1000 ) - + if (!retryFillSuccess) { log(false, 'CREATOR', 'Failed to fill new email after retries', 'error') return { success: false, email: null } } - + // Click Next to submit the new email const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() await nextBtn.click() await this.humanDelay(2000, 3000) await this.waitForPageStable('AUTO_RETRY_EMAIL', 15000) - + // Recursively check the new email (with retry count incremented) return await this.handleEmailErrors(newEmail, retryCount + 1) } - + // Find all suggestion buttons const suggestionButtons = await suggestionsContainer.locator('button').all() log(false, 'CREATOR', `Found ${suggestionButtons.length} suggestion buttons`, 'log', 'cyan') - + if (suggestionButtons.length === 0) { log(false, 'CREATOR', 'Suggestions container found but no buttons inside', 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } - + // Get text from first suggestion before clicking const firstButton = suggestionButtons[0] if (!firstButton) { log(false, 'CREATOR', 'First button is undefined', 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } - + const suggestedEmail = await firstButton.textContent().catch(() => '') || '' let cleanEmail = suggestedEmail.trim() - + // If suggestion doesn't have @domain, it's just the username - add @outlook.com if (cleanEmail && !cleanEmail.includes('@')) { cleanEmail = `${cleanEmail}@outlook.com` log(false, 'CREATOR', `Suggestion is username only, adding domain: ${cleanEmail}`, 'log', 'cyan') } - + if (!cleanEmail) { log(false, 'CREATOR', 'Could not extract email from suggestion button', 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } - + log(false, 'CREATOR', `Selecting suggestion: ${cleanEmail}`, 'log', 'cyan') - + // Click the suggestion await firstButton.click() await this.humanDelay(1500, 2500) - + // Verify the email input was updated const emailInput = this.page.locator('input[type="email"]').first() const inputValue = await emailInput.inputValue().catch(() => '') - + if (inputValue) { log(false, 'CREATOR', `✅ Suggestion applied: ${inputValue}`, 'log', 'green') } - + // Check if error is gone const errorLocator = this.page.locator('div[id*="Error"], div[role="alert"]').first() const errorStillVisible = await errorLocator.isVisible().catch(() => false) - + if (errorStillVisible) { log(false, 'CREATOR', 'Error still visible after clicking suggestion', 'warn', 'yellow') - + // Try clicking Next to submit const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() const nextEnabled = await nextBtn.isEnabled().catch(() => false) - + if (nextEnabled) { log(false, 'CREATOR', 'Clicking Next to submit suggestion', 'log') await nextBtn.click() await this.humanDelay(2000, 3000) - + // Final check const finalError = await errorLocator.isVisible().catch(() => false) if (finalError) { log(false, 'CREATOR', 'Failed to resolve error', 'error') log(false, 'CREATOR', '⚠️ Browser left open. Press Ctrl+C to exit.', 'warn', 'yellow') - await new Promise(() => {}) + await new Promise(() => { }) return { success: false, email: null } } } @@ -1157,92 +1157,92 @@ export class AccountCreator { await nextBtn.click() await this.humanDelay(2000, 3000) } - + log(false, 'CREATOR', `✅ Using suggested email: ${cleanEmail}`, 'log', 'green') return { success: true, email: cleanEmail } } private async clickNext(step: string): Promise { log(false, 'CREATOR', `Clicking Next button (${step})...`, 'log') - + // CRITICAL: Ensure page is stable before clicking await this.waitForPageStable(`BEFORE_NEXT_${step.toUpperCase()}`, 15000) - + // Find button by test id or type submit const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() - + // CRITICAL: Verify button is ready const isReady = await this.verifyElementReady( 'button[data-testid="primaryButton"], button[type="submit"]', `NEXT_BUTTON_${step.toUpperCase()}`, 15000 ) - + if (!isReady) { log(false, 'CREATOR', 'Next button not ready, waiting longer...', 'warn', 'yellow') await this.humanDelay(3000, 5000) } - + // Ensure button is enabled const isEnabled = await nextBtn.isEnabled() if (!isEnabled) { log(false, 'CREATOR', 'Waiting for Next button to be enabled...', 'warn') await this.humanDelay(3000, 5000) } - + // Get current URL and page state before clicking const urlBefore = this.page.url() - + await nextBtn.click() log(false, 'CREATOR', `✅ Clicked Next (${step})`, 'log', 'green') - + // CRITICAL: Wait for page to process the click await this.humanDelay(3000, 5000) - + // CRITICAL: Wait for page to be stable after clicking await this.waitForPageStable(`AFTER_NEXT_${step.toUpperCase()}`, 20000) - + // CRITICAL: Verify the click was successful const urlAfter = this.page.url() let clickSuccessful = false - + if (urlBefore !== urlAfter) { log(false, 'CREATOR', `✅ Navigation detected: ${urlBefore} → ${urlAfter}`, 'log', 'green') clickSuccessful = true } else { log(false, 'CREATOR', `URL unchanged after clicking Next (${step})`, 'log', 'yellow') - + // URL didn't change - this might be OK if content changed // Wait a bit more and check for errors await this.humanDelay(2000, 3000) - + const hasErrors = !(await this.verifyNoErrors()) if (hasErrors) { log(false, 'CREATOR', `❌ Errors detected after clicking Next (${step})`, 'error') return false } - + // No errors - assume success (some pages don't change URL) log(false, 'CREATOR', `No errors detected, assuming Next (${step}) was successful`, 'log', 'yellow') clickSuccessful = true } - + return clickSuccessful } private async fillPassword(): Promise { - + await this.page.locator('h1[data-testid="title"]').first().waitFor({ timeout: 20000 }) await this.waitForPageStable('PASSWORD_PAGE', 15000) await this.humanDelay(1000, 2000) - + log(false, 'CREATOR', '🔐 Generating password...', 'log', 'cyan') const password = this.dataGenerator.generatePassword() - + const passwordInput = this.page.locator('input[type="password"]').first() await passwordInput.waitFor({ timeout: 15000 }) - + // CRITICAL: Retry fill with verification const passwordFillSuccess = await this.retryOperation( async () => { @@ -1250,33 +1250,33 @@ export class AccountCreator { await this.humanDelay(800, 1500) // INCREASED from 500-1000 await passwordInput.fill(password) await this.humanDelay(1200, 2500) // INCREASED from 800-2000 - + // Verify value was filled correctly const verified = await this.verifyInputValue('input[type="password"]', password) if (!verified) { throw new Error('Password input value not verified') } - + return true }, 'PASSWORD_FILL', 3, 1000 ) - + if (!passwordFillSuccess) { log(false, 'CREATOR', 'Failed to fill password after retries', 'error') return null } - + log(false, 'CREATOR', '✅ Password filled (hidden for security)', 'log', 'green') - + return password } private async extractEmail(): Promise { - + // Multiple selectors for identity badge (language-independent) const badgeSelectors = [ '#bannerText', @@ -1285,14 +1285,14 @@ export class AccountCreator { 'div[class*="identityBanner"]', 'span[class*="identityText"]' ] - + for (const selector of badgeSelectors) { try { const badge = this.page.locator(selector).first() await badge.waitFor({ timeout: 5000 }) - + const email = await badge.textContent() - + if (email && email.includes('@')) { const cleanEmail = email.trim() log(false, 'CREATOR', `✅ Email extracted: ${cleanEmail}`, 'log', 'green') @@ -1303,104 +1303,104 @@ export class AccountCreator { continue } } - + log(false, 'CREATOR', 'Could not find identity badge (not critical)', 'warn') return null } private async fillBirthdate(): Promise<{ day: number; month: number; year: number } | null> { log(false, 'CREATOR', '🎂 Filling birthdate...', 'log', 'cyan') - + await this.waitForPageStable('BIRTHDATE_PAGE', 15000) - + const birthdate = this.dataGenerator.generateBirthdate() - + try { await this.humanDelay(2000, 3000) - + // === DAY DROPDOWN === const dayButton = this.page.locator('button[name="BirthDay"], button#BirthDayDropdown').first() await dayButton.waitFor({ timeout: 15000, state: 'visible' }) - + log(false, 'CREATOR', 'Clicking day dropdown...', 'log') - + // CRITICAL: Retry click if it fails const dayClickSuccess = await this.retryOperation( async () => { await dayButton.click({ force: true }) await this.humanDelay(1500, 2500) // INCREASED delay - + // Verify dropdown opened const dayOptionsContainer = this.page.locator('div[role="listbox"], ul[role="listbox"]').first() const isOpen = await dayOptionsContainer.isVisible().catch(() => false) - + if (!isOpen) { throw new Error('Day dropdown did not open') } - + return true }, 'DAY_DROPDOWN_OPEN', 3, 1000 ) - + if (!dayClickSuccess) { log(false, 'CREATOR', 'Failed to open day dropdown after retries', 'error') return null } - + log(false, 'CREATOR', '✅ Day dropdown opened', 'log', 'green') - + // Select day from dropdown log(false, 'CREATOR', `Selecting day: ${birthdate.day}`, 'log') const dayOption = this.page.locator(`div[role="option"]:has-text("${birthdate.day}"), li[role="option"]:has-text("${birthdate.day}")`).first() await dayOption.waitFor({ timeout: 5000, state: 'visible' }) await dayOption.click() await this.humanDelay(1500, 2500) // INCREASED delay - + // CRITICAL: Wait for dropdown to FULLY close await this.waitForDropdownClosed('DAY_DROPDOWN', 8000) await this.humanDelay(2000, 3000) // INCREASED safety delay - + // === MONTH DROPDOWN === const monthButton = this.page.locator('button[name="BirthMonth"], button#BirthMonthDropdown').first() await monthButton.waitFor({ timeout: 10000, state: 'visible' }) - + log(false, 'CREATOR', 'Clicking month dropdown...', 'log') - + // CRITICAL: Retry click if it fails const monthClickSuccess = await this.retryOperation( async () => { await monthButton.click({ force: true }) await this.humanDelay(1500, 2500) // INCREASED delay - + // Verify dropdown opened const monthOptionsContainer = this.page.locator('div[role="listbox"], ul[role="listbox"]').first() const isOpen = await monthOptionsContainer.isVisible().catch(() => false) - + if (!isOpen) { throw new Error('Month dropdown did not open') } - + return true }, 'MONTH_DROPDOWN_OPEN', 3, 1000 ) - + if (!monthClickSuccess) { log(false, 'CREATOR', 'Failed to open month dropdown after retries', 'error') return null } - + log(false, 'CREATOR', '✅ Month dropdown opened', 'log', 'green') - + // Select month by data-value attribute or by position log(false, 'CREATOR', `Selecting month: ${birthdate.month}`, 'log') const monthOption = this.page.locator(`div[role="option"][data-value="${birthdate.month}"], li[role="option"][data-value="${birthdate.month}"]`).first() - + // Fallback: if data-value doesn't work, try by index const monthVisible = await monthOption.isVisible().catch(() => false) if (monthVisible) { @@ -1412,17 +1412,17 @@ export class AccountCreator { await monthOptionByIndex.click() } await this.humanDelay(1500, 2500) // INCREASED delay - + // CRITICAL: Wait for dropdown to FULLY close await this.waitForDropdownClosed('MONTH_DROPDOWN', 8000) await this.humanDelay(2000, 3000) // INCREASED safety delay - + // === YEAR INPUT === const yearInput = this.page.locator('input[name="BirthYear"], input[type="number"]').first() await yearInput.waitFor({ timeout: 10000, state: 'visible' }) - + log(false, 'CREATOR', `Filling year: ${birthdate.year}`, 'log') - + // CRITICAL: Retry fill with verification const yearFillSuccess = await this.retryOperation( async () => { @@ -1430,62 +1430,62 @@ export class AccountCreator { await this.humanDelay(500, 1000) await yearInput.fill(birthdate.year.toString()) await this.humanDelay(1000, 2000) - + // Verify value was filled correctly const verified = await this.verifyInputValue( 'input[name="BirthYear"], input[type="number"]', birthdate.year.toString() ) - + if (!verified) { throw new Error('Year input value not verified') } - + return true }, 'YEAR_FILL', 3, 1000 ) - + if (!yearFillSuccess) { log(false, 'CREATOR', 'Failed to fill year after retries', 'error') return null } - + log(false, 'CREATOR', `✅ Birthdate filled: ${birthdate.day}/${birthdate.month}/${birthdate.year}`, 'log', 'green') - + // CRITICAL: Verify no errors appeared after filling birthdate const noErrors = await this.verifyNoErrors() if (!noErrors) { log(false, 'CREATOR', '❌ Errors detected after filling birthdate', 'error') return null } - + // CRITICAL: Verify Next button is enabled (indicates form is valid) await this.humanDelay(1000, 2000) const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() const nextEnabled = await nextBtn.isEnabled().catch(() => false) - + if (!nextEnabled) { log(false, 'CREATOR', '⚠️ Next button not enabled after filling birthdate', 'warn', 'yellow') log(false, 'CREATOR', 'Waiting for form validation...', 'log', 'cyan') await this.humanDelay(3000, 5000) - + const retryEnabled = await nextBtn.isEnabled().catch(() => false) if (!retryEnabled) { log(false, 'CREATOR', '❌ Next button still disabled - form may be invalid', 'error') return null } } - + log(false, 'CREATOR', '✅ Birthdate form validated successfully', 'log', 'green') - + // CRITICAL: Extra safety delay before submitting await this.humanDelay(2000, 3000) - + return birthdate - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `Error filling birthdate: ${msg}`, 'error') @@ -1495,14 +1495,14 @@ export class AccountCreator { private async fillNames(email: string): Promise<{ firstName: string; lastName: string } | null> { log(false, 'CREATOR', '👤 Filling name...', 'log', 'cyan') - + await this.waitForPageStable('NAMES_PAGE', 15000) - + const names = this.dataGenerator.generateNames(email) - + try { await this.humanDelay(1000, 2000) - + const firstNameSelectors = [ 'input[id*="firstName"]', 'input[name*="firstName"]', @@ -1511,7 +1511,7 @@ export class AccountCreator { 'input[aria-label*="First"]', 'input[placeholder*="First"]' ] - + let firstNameInput = null for (const selector of firstNameSelectors) { const input = this.page.locator(selector).first() @@ -1521,12 +1521,12 @@ export class AccountCreator { break } } - + if (!firstNameInput) { log(false, 'CREATOR', 'Could not find first name input', 'error') return null } - + // CRITICAL: Retry fill with verification const firstNameFillSuccess = await this.retryOperation( async () => { @@ -1534,19 +1534,19 @@ export class AccountCreator { await this.humanDelay(800, 1500) // INCREASED from 500-1000 await firstNameInput.fill(names.firstName) await this.humanDelay(1200, 2500) // INCREASED from 800-2000 - + return true }, 'FIRSTNAME_FILL', 3, 1000 ) - + if (!firstNameFillSuccess) { log(false, 'CREATOR', 'Failed to fill first name after retries', 'error') return null } - + // Fill last name with multiple selector fallbacks const lastNameSelectors = [ 'input[id*="lastName"]', @@ -1556,7 +1556,7 @@ export class AccountCreator { 'input[aria-label*="Last"]', 'input[placeholder*="Last"]' ] - + let lastNameInput = null for (const selector of lastNameSelectors) { const input = this.page.locator(selector).first() @@ -1566,12 +1566,12 @@ export class AccountCreator { break } } - + if (!lastNameInput) { log(false, 'CREATOR', 'Could not find last name input', 'error') return null } - + // CRITICAL: Retry fill with verification const lastNameFillSuccess = await this.retryOperation( async () => { @@ -1579,52 +1579,52 @@ export class AccountCreator { await this.humanDelay(800, 1500) // INCREASED from 500-1000 await lastNameInput.fill(names.lastName) await this.humanDelay(1200, 2500) // INCREASED from 800-2000 - + return true }, 'LASTNAME_FILL', 3, 1000 ) - + if (!lastNameFillSuccess) { log(false, 'CREATOR', 'Failed to fill last name after retries', 'error') return null } - + log(false, 'CREATOR', `✅ Names filled: ${names.firstName} ${names.lastName}`, 'log', 'green') - + // CRITICAL: Uncheck marketing opt-in checkbox (decline promotional emails) await this.uncheckMarketingOptIn() - + // CRITICAL: Verify no errors appeared after filling names const noErrors = await this.verifyNoErrors() if (!noErrors) { log(false, 'CREATOR', '❌ Errors detected after filling names', 'error') return null } - + // CRITICAL: Verify Next button is enabled (indicates form is valid) await this.humanDelay(1000, 2000) const nextBtn = this.page.locator('button[data-testid="primaryButton"], button[type="submit"]').first() const nextEnabled = await nextBtn.isEnabled().catch(() => false) - + if (!nextEnabled) { log(false, 'CREATOR', '⚠️ Next button not enabled after filling names', 'warn', 'yellow') log(false, 'CREATOR', 'Waiting for form validation...', 'log', 'cyan') await this.humanDelay(3000, 5000) - + const retryEnabled = await nextBtn.isEnabled().catch(() => false) if (!retryEnabled) { log(false, 'CREATOR', '❌ Next button still disabled - form may be invalid', 'error') return null } } - + log(false, 'CREATOR', '✅ Names form validated successfully', 'log', 'green') - + return names - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `Error filling names: ${msg}`, 'error') @@ -1635,7 +1635,7 @@ export class AccountCreator { private async uncheckMarketingOptIn(): Promise { try { log(false, 'CREATOR', 'Checking for marketing opt-in checkbox...', 'log', 'cyan') - + // Multiple selectors for the marketing checkbox const checkboxSelectors = [ 'input#marketingOptIn', @@ -1643,7 +1643,7 @@ export class AccountCreator { 'input[name="marketingOptIn"]', 'input[aria-label*="information, tips, and offers"]' ] - + let checkbox = null for (const selector of checkboxSelectors) { const element = this.page.locator(selector).first() @@ -1654,22 +1654,22 @@ export class AccountCreator { break } } - + if (!checkbox) { log(false, 'CREATOR', 'No marketing checkbox found (may not exist on this page)', 'log', 'gray') return } - + // Check if the checkbox is already checked const isChecked = await checkbox.isChecked().catch(() => false) - + if (isChecked) { log(false, 'CREATOR', 'Marketing checkbox is checked, unchecking it...', 'log', 'yellow') - + // Click to uncheck await checkbox.click() await this.humanDelay(500, 1000) - + // Verify it was unchecked const stillChecked = await checkbox.isChecked().catch(() => true) if (!stillChecked) { @@ -1680,7 +1680,7 @@ export class AccountCreator { } else { log(false, 'CREATOR', '✅ Marketing opt-in already unchecked', 'log', 'green') } - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `Marketing opt-in handling error: ${msg}`, 'warn', 'yellow') @@ -1692,16 +1692,16 @@ export class AccountCreator { try { log(false, 'CREATOR', '🔍 Checking for CAPTCHA...', 'log', 'cyan') await this.humanDelay(1500, 2500) - + // Check for CAPTCHA iframe (most reliable) const captchaIframe = this.page.locator('iframe[data-testid="humanCaptchaIframe"]').first() const iframeVisible = await captchaIframe.isVisible().catch(() => false) - + if (iframeVisible) { log(false, 'CREATOR', '🤖 CAPTCHA DETECTED via iframe - WAITING FOR HUMAN', 'warn', 'yellow') return true } - + // Check multiple CAPTCHA indicators const captchaIndicators = [ 'h1[data-testid="title"]', @@ -1710,18 +1710,18 @@ export class AccountCreator { 'div[id*="enforcement"]', 'img[data-testid="accessibleImg"]' ] - + for (const selector of captchaIndicators) { const element = this.page.locator(selector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { const text = await element.textContent().catch(() => '') log(false, 'CREATOR', `Found element: ${selector} = "${text?.substring(0, 50)}"`, 'log', 'gray') - + if (text && ( - text.toLowerCase().includes('vérif') || - text.toLowerCase().includes('verify') || + text.toLowerCase().includes('vérif') || + text.toLowerCase().includes('verify') || text.toLowerCase().includes('human') || text.toLowerCase().includes('humain') || text.toLowerCase().includes('puzzle') || @@ -1733,7 +1733,7 @@ export class AccountCreator { } } } - + log(false, 'CREATOR', '✅ No CAPTCHA detected', 'log', 'green') return false } catch (error) { @@ -1746,7 +1746,7 @@ export class AccountCreator { const maxWaitTime = 10 * 60 * 1000 const startTime = Date.now() let lastLogTime = startTime - + while (Date.now() - startTime < maxWaitTime) { try { if (Date.now() - lastLogTime > 30000) { @@ -1754,43 +1754,43 @@ export class AccountCreator { log(false, 'CREATOR', `⏳ Still waiting for CAPTCHA solution... (${elapsed}s)`, 'log', 'yellow') lastLogTime = Date.now() } - + const captchaStillPresent = await this.waitForCaptcha() - + if (!captchaStillPresent) { log(false, 'CREATOR', '✅ CAPTCHA SOLVED! Processing account creation...', 'log', 'green') - + await this.humanDelay(3000, 5000) await this.waitForAccountCreation() await this.humanDelay(2000, 3000) - + return } - + await this.page.waitForTimeout(2000) - + } catch (error) { log(false, 'CREATOR', `Error in CAPTCHA wait: ${error}`, 'warn', 'yellow') return } } - + throw new Error('CAPTCHA timeout - 10 minutes exceeded') } private async handlePostCreationQuestions(): Promise { log(false, 'CREATOR', 'Handling post-creation questions...', 'log', 'cyan') - + // Wait for page to stabilize (REDUCED - pages load fast) await this.waitForPageStable('POST_CREATION', 15000) await this.humanDelay(3000, 5000) - + // CRITICAL: Handle passkey prompt - MUST REFUSE await this.handlePasskeyPrompt() - + // Brief delay between prompts await this.humanDelay(2000, 3000) - + // Handle "Stay signed in?" (KMSI) prompt const kmsiSelectors = [ '[data-testid="kmsiVideo"]', @@ -1798,21 +1798,21 @@ export class AccountCreator { 'div:has-text("Rester connecté")', 'button[data-testid="primaryButton"]' ] - + for (let i = 0; i < 3; i++) { let found = false - + for (const selector of kmsiSelectors) { const element = this.page.locator(selector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { log(false, 'CREATOR', 'Stay signed in prompt detected', 'log', 'yellow') - + // Click "Yes" button const yesButton = this.page.locator('button[data-testid="primaryButton"]').first() const yesVisible = await yesButton.isVisible().catch(() => false) - + if (yesVisible) { await yesButton.click() await this.humanDelay(2000, 3000) @@ -1823,29 +1823,29 @@ export class AccountCreator { } } } - + if (!found) break await this.humanDelay(1000, 2000) } - + // Handle any other prompts (biometric, etc.) const genericPrompts = [ '[data-testid="biometricVideo"]', 'button[id*="close"]', 'button[aria-label*="Close"]' ] - + for (const selector of genericPrompts) { const element = this.page.locator(selector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { log(false, 'CREATOR', `Closing prompt: ${selector}`, 'log', 'yellow') - + // Try to close it const closeButton = this.page.locator('button[data-testid="secondaryButton"], button[id*="close"]').first() const closeVisible = await closeButton.isVisible().catch(() => false) - + if (closeVisible) { await closeButton.click() await this.humanDelay(1500, 2500) @@ -1853,19 +1853,19 @@ export class AccountCreator { } } } - + log(false, 'CREATOR', '✅ Post-creation questions handled', 'log', 'green') } - + private async handlePasskeyPrompt(): Promise { log(false, 'CREATOR', 'Checking for passkey setup prompt...', 'log', 'cyan') - + // Wait for passkey prompt to appear (REDUCED) await this.humanDelay(3000, 5000) - + // Ensure page is stable before checking await this.waitForPageStable('PASSKEY_CHECK', 15000) - + // Multiple selectors for passkey prompt detection const passkeyDetectionSelectors = [ 'div:has-text("passkey")', @@ -1878,25 +1878,25 @@ export class AccountCreator { 'button:has-text("Ignorer")', 'button:has-text("Plus tard")' ] - + let passkeyPromptFound = false - + for (const selector of passkeyDetectionSelectors) { const element = this.page.locator(selector).first() const visible = await element.isVisible().catch(() => false) - + if (visible) { passkeyPromptFound = true log(false, 'CREATOR', '⚠️ Passkey setup prompt detected - REFUSING', 'warn', 'yellow') break } } - + if (!passkeyPromptFound) { log(false, 'CREATOR', 'No passkey prompt detected', 'log', 'green') return } - + // Try to click refuse/skip buttons const refuseButtonSelectors = [ 'button:has-text("Skip")', @@ -1911,11 +1911,11 @@ export class AccountCreator { 'button[id*="cancel"]', 'button[id*="skip"]' ] - + for (const selector of refuseButtonSelectors) { const button = this.page.locator(selector).first() const visible = await button.isVisible().catch(() => false) - + if (visible) { log(false, 'CREATOR', `Clicking refuse button: ${selector}`, 'log', 'cyan') await button.click() @@ -1924,54 +1924,54 @@ export class AccountCreator { return } } - + log(false, 'CREATOR', '⚠️ Could not find refuse button for passkey prompt', 'warn', 'yellow') } private async verifyAccountActive(): Promise { log(false, 'CREATOR', 'Verifying account is active...', 'log', 'cyan') - + // Ensure page is stable before navigating (REDUCED) await this.waitForPageStable('PRE_VERIFICATION', 10000) await this.humanDelay(3000, 5000) - + // Navigate to Bing Rewards try { log(false, 'CREATOR', 'Navigating to rewards.bing.com...', 'log', 'cyan') - - await this.page.goto('https://rewards.bing.com/', { + + await this.page.goto('https://rewards.bing.com/', { waitUntil: 'networkidle', timeout: 30000 }) - + await this.waitForPageStable('REWARDS_PAGE', 7000) await this.humanDelay(2000, 3000) - + log(false, 'CREATOR', '✅ On rewards.bing.com', 'log', 'green') - + // Clear cookies on rewards page await this.dismissCookieBanner() - + // Handle "Get started" popup (ReferAndEarn) await this.humanDelay(2000, 3000) await this.handleGetStartedPopup() - + // Referral enrollment if needed if (this.referralUrl) { await this.humanDelay(2000, 3000) await this.ensureRewardsEnrollment() } - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `Warning: Could not verify account: ${msg}`, 'warn', 'yellow') } } - + private async dismissCookieBanner(): Promise { try { log(false, 'CREATOR', '🍪 Checking for cookie banner...', 'log', 'cyan') - + const rejectButtonSelectors = [ 'button#bnp_btn_reject', 'button[id*="reject"]', @@ -1980,11 +1980,11 @@ export class AccountCreator { 'a:has-text("Reject")', 'a:has-text("Refuser")' ] - + for (const selector of rejectButtonSelectors) { const button = this.page.locator(selector).first() const visible = await button.isVisible({ timeout: 2000 }).catch(() => false) - + if (visible) { log(false, 'CREATOR', '✅ Rejecting cookies', 'log', 'green') await button.click() @@ -1992,7 +1992,7 @@ export class AccountCreator { return } } - + log(false, 'CREATOR', 'No cookie banner found', 'log', 'gray') } catch (error) { log(false, 'CREATOR', `Cookie banner error: ${error}`, 'log', 'gray') @@ -2003,23 +2003,23 @@ export class AccountCreator { try { log(false, 'CREATOR', '🎯 Checking for "Get started" popup...', 'log', 'cyan') await this.humanDelay(2000, 3000) - + // Check for ReferAndEarn popup const popupIndicator = this.page.locator('img[src*="ReferAndEarnPopUpImgUpdated"]').first() const popupVisible = await popupIndicator.isVisible({ timeout: 3000 }).catch(() => false) - + if (!popupVisible) { log(false, 'CREATOR', 'No "Get started" popup found', 'log', 'gray') return } - + log(false, 'CREATOR', '✅ Found "Get started" popup', 'log', 'green') await this.humanDelay(1000, 2000) - + // Click "Get started" button const getStartedButton = this.page.locator('a#reward_pivot_earn, a.dashboardPopUpPopUpSelectButton').first() const buttonVisible = await getStartedButton.isVisible({ timeout: 2000 }).catch(() => false) - + if (buttonVisible) { log(false, 'CREATOR', '🎯 Clicking "Get started"', 'log', 'cyan') await getStartedButton.click() @@ -2120,7 +2120,7 @@ export class AccountCreator { } } */ - + /* private async handleRewardsPopups(): Promise { await this.waitForPageStable('REWARDS_POPUPS', 10000) @@ -2193,25 +2193,25 @@ export class AccountCreator { } } */ - + private async ensureRewardsEnrollment(): Promise { if (!this.referralUrl) return - + try { log(false, 'CREATOR', '🔗 Reloading referral URL for enrollment...', 'log', 'cyan') - - await this.page.goto(this.referralUrl, { + + await this.page.goto(this.referralUrl, { waitUntil: 'networkidle', timeout: 30000 }) - + await this.waitForPageStable('REFERRAL_ENROLLMENT', 7000) await this.humanDelay(2000, 3000) - + // Click "Join Microsoft Rewards" button const joinButton = this.page.locator('a#start-earning-rewards-link').first() const joinVisible = await joinButton.isVisible({ timeout: 3000 }).catch(() => false) - + if (joinVisible) { log(false, 'CREATOR', '🎯 Clicking "Join Microsoft Rewards"', 'log', 'cyan') await joinButton.click() @@ -2221,9 +2221,9 @@ export class AccountCreator { } else { log(false, 'CREATOR', '✅ Already enrolled or Join button not found', 'log', 'gray') } - + log(false, 'CREATOR', '✅ Enrollment process completed', 'log', 'green') - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `Warning: Could not complete enrollment: ${msg}`, 'warn', 'yellow') @@ -2233,28 +2233,28 @@ export class AccountCreator { private async saveAccount(account: CreatedAccount): Promise { try { const accountsDir = path.join(process.cwd(), 'accounts-created') - + // Ensure directory exists if (!fs.existsSync(accountsDir)) { log(false, 'CREATOR', 'Creating accounts-created directory...', 'log', 'cyan') fs.mkdirSync(accountsDir, { recursive: true }) } - + // Create a unique filename for THIS account using timestamp and email const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\./g, '-') const emailPrefix = (account.email.split('@')[0] || 'account').substring(0, 20) // First 20 chars of email const filename = `account_${emailPrefix}_${timestamp}.jsonc` const filepath = path.join(accountsDir, filename) - + log(false, 'CREATOR', `Saving account to NEW file: ${filename}`, 'log', 'cyan') - + // Create account data with metadata const accountData = { ...account, savedAt: new Date().toISOString(), filename: filename } - + // Create output with comments const output = `// Microsoft Rewards - Account Created // Email: ${account.email} @@ -2262,24 +2262,24 @@ export class AccountCreator { // Saved: ${accountData.savedAt} ${JSON.stringify(accountData, null, 2)}` - + // Write to NEW file (never overwrites existing files) fs.writeFileSync(filepath, output, 'utf-8') - + // Verify the file was written correctly if (fs.existsSync(filepath)) { const verifySize = fs.statSync(filepath).size log(false, 'CREATOR', `✅ File written successfully (${verifySize} bytes)`, 'log', 'green') - + // Double-check we can read it back const verifyContent = fs.readFileSync(filepath, 'utf-8') const verifyJsonStartIndex = verifyContent.indexOf('{') const verifyJsonEndIndex = verifyContent.lastIndexOf('}') - + if (verifyJsonStartIndex !== -1 && verifyJsonEndIndex !== -1) { const verifyJsonContent = verifyContent.substring(verifyJsonStartIndex, verifyJsonEndIndex + 1) const verifyAccount = JSON.parse(verifyJsonContent) - + if (verifyAccount.email === account.email) { log(false, 'CREATOR', `✅ Verification passed: Account ${account.email} saved correctly`, 'log', 'green') } else { @@ -2289,13 +2289,13 @@ ${JSON.stringify(accountData, null, 2)}` } else { log(false, 'CREATOR', '❌ File verification failed - file does not exist!', 'error') } - + log(false, 'CREATOR', `✅ Account saved successfully to: ${filepath}`, 'log', 'green') - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR', `❌ Error saving account: ${msg}`, 'error') - + // Try to save to a fallback file try { const fallbackPath = path.join(process.cwd(), `account-backup-${Date.now()}.jsonc`) @@ -2324,70 +2324,70 @@ ${JSON.stringify(accountData, null, 2)}` private async setupRecoveryEmail(): Promise { try { log(false, 'CREATOR', '📧 Setting up recovery email...', 'log', 'cyan') - + // Navigate to proofs manage page - await this.page.goto('https://account.live.com/proofs/manage/', { + await this.page.goto('https://account.live.com/proofs/manage/', { waitUntil: 'networkidle', timeout: 30000 }) - + await this.humanDelay(2000, 3000) - + // Check if we're on the "Add security info" page const addProofTitle = await this.page.locator('#iPageTitle').textContent().catch(() => '') - + if (!addProofTitle || !addProofTitle.includes('protect your account')) { log(false, 'CREATOR', 'Already on security dashboard', 'log', 'gray') return undefined } - + log(false, 'CREATOR', '🔒 Security setup page detected', 'log', 'yellow') - + // Get recovery email let recoveryEmailToUse = this.recoveryEmail - + if (!recoveryEmailToUse && !this.autoAccept) { recoveryEmailToUse = await this.askRecoveryEmail() } - + if (!recoveryEmailToUse) { log(false, 'CREATOR', 'Skipping recovery email setup', 'log', 'gray') return undefined } - + log(false, 'CREATOR', `Using recovery email: ${recoveryEmailToUse}`, 'log', 'cyan') - + // Fill email input const emailInput = this.page.locator('#EmailAddress').first() await emailInput.fill(recoveryEmailToUse) await this.humanDelay(500, 1000) - + // Click Next const nextButton = this.page.locator('#iNext').first() await nextButton.click() - + log(false, 'CREATOR', '📨 Code sent to recovery email', 'log', 'green') log(false, 'CREATOR', '⏳ Please enter the code you received and click Next', 'log', 'yellow') log(false, 'CREATOR', 'Waiting for you to complete verification...', 'log', 'cyan') - + // Wait for URL change (user completes verification) await this.page.waitForURL((url) => !url.href.includes('/proofs/Verify'), { timeout: 300000 }) - + log(false, 'CREATOR', '✅ Recovery email verified!', 'log', 'green') - + // Click OK on "Quick note" page if present await this.humanDelay(2000, 3000) const okButton = this.page.locator('button:has-text("OK")').first() const okVisible = await okButton.isVisible({ timeout: 5000 }).catch(() => false) - + if (okVisible) { await okButton.click() await this.humanDelay(1000, 2000) log(false, 'CREATOR', '✅ Clicked OK on info page', 'log', 'green') } - + return recoveryEmailToUse - + } catch (error) { log(false, 'CREATOR', `Recovery email setup error: ${error}`, 'warn', 'yellow') return undefined @@ -2427,32 +2427,32 @@ ${JSON.stringify(accountData, null, 2)}` private async setup2FA(): Promise<{ totpSecret: string; recoveryCode: string | undefined } | undefined> { try { log(false, 'CREATOR', '🔐 Setting up 2FA...', 'log', 'cyan') - + // Navigate to 2FA setup page - await this.page.goto('https://account.live.com/proofs/EnableTfa', { + await this.page.goto('https://account.live.com/proofs/EnableTfa', { waitUntil: 'networkidle', timeout: 30000 }) - + await this.humanDelay(2000, 3000) - + // Click Next const submitButton = this.page.locator('#EnableTfaSubmit').first() await submitButton.click() await this.humanDelay(2000, 3000) - + // Click "set up a different Authenticator app" const altAppLink = this.page.locator('#iSelectProofTypeAlternate').first() const altAppVisible = await altAppLink.isVisible({ timeout: 5000 }).catch(() => false) - + if (altAppVisible) { await altAppLink.click() await this.humanDelay(2000, 3000) } - + // IMPROVED: Click "I can't scan the bar code" with fallback selectors log(false, 'CREATOR', '🔍 Looking for "I can\'t scan" link...', 'log', 'cyan') - + const cantScanSelectors = [ '#iShowPlainLink', // Primary 'a[href*="ShowPlain"]', // Link with ShowPlain in href @@ -2462,13 +2462,13 @@ ${JSON.stringify(accountData, null, 2)}` 'button:has-text("I can\'t scan the bar code")', // Full text 'a:has-text("I can\'t scan the bar code")' // Full text link ] - + let cantScanClicked = false for (const selector of cantScanSelectors) { try { const element = this.page.locator(selector).first() const isVisible = await element.isVisible({ timeout: 2000 }).catch(() => false) - + if (isVisible) { log(false, 'CREATOR', `✅ Found "I can't scan" using: ${selector}`, 'log', 'green') await element.click() @@ -2479,16 +2479,16 @@ ${JSON.stringify(accountData, null, 2)}` continue } } - + if (!cantScanClicked) { log(false, 'CREATOR', '⚠️ Could not find "I can\'t scan" link - trying to continue anyway', 'warn', 'yellow') } - + await this.humanDelay(2000, 3000) // Wait for UI to update and secret to appear - + // IMPROVED: Extract TOTP secret with multiple strategies log(false, 'CREATOR', '🔍 Searching for TOTP secret on page...', 'log', 'cyan') - + // Strategy 1: Wait for common TOTP secret selectors const secretSelectors = [ '#iTOTP_Secret', // Most common @@ -2501,16 +2501,16 @@ ${JSON.stringify(accountData, null, 2)}` 'pre', // Pre-formatted text 'code' // Code block ] - + let totpSecret = '' let foundSelector = '' - + // Try each selector with explicit wait for (const selector of secretSelectors) { try { const element = this.page.locator(selector).first() const isVisible = await element.isVisible({ timeout: 2000 }).catch(() => false) - + if (isVisible) { // Try multiple extraction methods const methods = [ @@ -2519,11 +2519,11 @@ ${JSON.stringify(accountData, null, 2)}` () => element.innerText().catch(() => ''), // Alternative text () => element.getAttribute('value').catch(() => '') // Value attribute ] - + for (const method of methods) { const value = await method() const cleaned = value?.trim() || '' - + // TOTP secrets are typically 16-32 characters, base32 encoded (A-Z, 2-7) if (cleaned && cleaned.length >= 16 && cleaned.length <= 64 && /^[A-Z2-7]+$/i.test(cleaned)) { totpSecret = cleaned.toUpperCase() @@ -2532,23 +2532,23 @@ ${JSON.stringify(accountData, null, 2)}` break } } - + if (totpSecret) break } } catch { continue } } - + // Strategy 2: If not found, scan entire page content if (!totpSecret) { log(false, 'CREATOR', '🔍 Scanning entire page for TOTP pattern...', 'log', 'yellow') - + const pageContent = await this.page.content().catch(() => '') // Look for base32 patterns (16-32 chars, only A-Z and 2-7) const secretPattern = /\b([A-Z2-7]{16,64})\b/g const matches = pageContent.match(secretPattern) - + if (matches && matches.length > 0) { // Filter out common false positives (IDs, tokens that are too long) const candidates = matches.filter(m => m.length >= 16 && m.length <= 32) @@ -2559,10 +2559,10 @@ ${JSON.stringify(accountData, null, 2)}` } } } - + if (!totpSecret) { log(false, 'CREATOR', '❌ Could not find TOTP secret', 'error') - + // Take screenshot for debugging try { const screenshotPath = path.join(process.cwd(), 'totp-secret-not-found.png') @@ -2571,32 +2571,32 @@ ${JSON.stringify(accountData, null, 2)}` } catch { log(false, 'CREATOR', '⚠️ Could not save debug screenshot', 'warn') } - + // Log page URL for manual check log(false, 'CREATOR', `📍 Current URL: ${this.page.url()}`, 'log', 'cyan') - + return undefined } - + log(false, 'CREATOR', `🔑 TOTP Secret: ${totpSecret} (found via: ${foundSelector})`, 'log', 'green') log(false, 'CREATOR', '⚠️ SAVE THIS SECRET - You will need it to generate codes!', 'warn', 'yellow') - + // Click "I'll scan a bar code instead" to go back to QR code view // (Same link, but now says "I'll scan a bar code instead") log(false, 'CREATOR', '🔄 Returning to QR code view...', 'log', 'cyan') - + const backToQRSelectors = [ '#iShowPlainLink', // Same element, different text now 'a:has-text("I\'ll scan")', // Text-based 'a:has-text("scan a bar code instead")', // Full text 'button:has-text("bar code instead")' // Button variant ] - + for (const selector of backToQRSelectors) { try { const element = this.page.locator(selector).first() const isVisible = await element.isVisible({ timeout: 2000 }).catch(() => false) - + if (isVisible) { await element.click() log(false, 'CREATOR', '✅ Returned to QR code view', 'log', 'green') @@ -2606,36 +2606,36 @@ ${JSON.stringify(accountData, null, 2)}` continue } } - + await this.humanDelay(1000, 2000) - + log(false, 'CREATOR', '📱 Please scan the QR code with Google Authenticator or similar app', 'log', 'yellow') log(false, 'CREATOR', '⏳ Then enter the 6-digit code and click Next', 'log', 'cyan') log(false, 'CREATOR', 'Waiting for you to complete setup...', 'log', 'cyan') - + // Wait for "Two-step verification is turned on" page await this.page.waitForSelector('#RecoveryCode', { timeout: 300000 }) - + log(false, 'CREATOR', '✅ 2FA enabled!', 'log', 'green') - + // Extract recovery code const recoveryElement = this.page.locator('#NewRecoveryCode').first() const recoveryText = await recoveryElement.textContent().catch(() => '') || '' const recoveryMatch = recoveryText.match(/([A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})/) const recoveryCode = recoveryMatch ? recoveryMatch[1] : '' - + if (recoveryCode) { log(false, 'CREATOR', `🔐 Recovery Code: ${recoveryCode}`, 'log', 'green') log(false, 'CREATOR', '⚠️ SAVE THIS CODE - You can use it to recover your account!', 'warn', 'yellow') } else { log(false, 'CREATOR', '⚠️ Could not extract recovery code', 'warn', 'yellow') } - + // Click Next await this.humanDelay(2000, 3000) const recoveryNextButton = this.page.locator('#iOptTfaEnabledRecoveryCodeNext').first() await recoveryNextButton.click() - + // Click Next again await this.humanDelay(2000, 3000) const nextButton2 = this.page.locator('#iOptTfaEnabledNext').first() @@ -2644,7 +2644,7 @@ ${JSON.stringify(accountData, null, 2)}` await nextButton2.click() await this.humanDelay(2000, 3000) } - + // Click Finish const finishButton = this.page.locator('#EnableTfaFinish').first() const finishVisible = await finishButton.isVisible({ timeout: 3000 }).catch(() => false) @@ -2652,16 +2652,16 @@ ${JSON.stringify(accountData, null, 2)}` await finishButton.click() await this.humanDelay(1000, 2000) } - + log(false, 'CREATOR', '✅ 2FA setup complete!', 'log', 'green') - + if (!totpSecret) { log(false, 'CREATOR', '❌ TOTP secret missing - 2FA may not work', 'error') return undefined } - + return { totpSecret, recoveryCode } - + } catch (error) { log(false, 'CREATOR', `2FA setup error: ${error}`, 'warn', 'yellow') return undefined diff --git a/src/account-creation/cli.ts b/src/account-creation/cli.ts index af8cbd4..c790a97 100644 --- a/src/account-creation/cli.ts +++ b/src/account-creation/cli.ts @@ -1,6 +1,6 @@ import Browser from '../browser/Browser' import { MicrosoftRewardsBot } from '../index' -import { log } from '../util/Logger' +import { log } from '../util/notifications/Logger' import { AccountCreator } from './AccountCreator' async function main(): Promise { @@ -9,11 +9,11 @@ async function main(): Promise { let referralUrl: string | undefined let recoveryEmail: string | undefined let autoAccept = false - + // Parse arguments - ULTRA SIMPLE for (const arg of args) { if (!arg) continue - + if (arg === '-y' || arg === '--yes' || arg === 'y' || arg === 'Y') { autoAccept = true } else if (arg.startsWith('http')) { @@ -23,7 +23,7 @@ async function main(): Promise { recoveryEmail = arg } } - + // Banner log(false, 'CREATOR-CLI', '', 'log') // Empty line log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'cyan') @@ -34,18 +34,18 @@ async function main(): Promise { log(false, 'CREATOR-CLI', ' Only interact when explicitly asked (e.g., CAPTCHA solving).', 'warn', 'yellow') log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'cyan') log(false, 'CREATOR-CLI', '', 'log') // Empty line - + // Display detected arguments if (referralUrl) { log(false, 'CREATOR-CLI', `✅ Referral URL: ${referralUrl}`, 'log', 'green') } else { log(false, 'CREATOR-CLI', '⚠️ No referral URL - account will NOT be linked to rewards', 'warn', 'yellow') } - + if (recoveryEmail) { log(false, 'CREATOR-CLI', `✅ Recovery email: ${recoveryEmail}`, 'log', 'green') } - + if (autoAccept) { log(false, 'CREATOR-CLI', '⚡ Auto-accept mode ENABLED (-y flag detected)', 'log', 'green') log(false, 'CREATOR-CLI', '🤖 All prompts will be auto-accepted', 'log', 'cyan') @@ -53,17 +53,17 @@ async function main(): Promise { log(false, 'CREATOR-CLI', '🤖 Interactive mode: you will be asked for options', 'log', 'cyan') log(false, 'CREATOR-CLI', '💡 Tip: Use -y flag to auto-accept all prompts', 'log', 'gray') } - + log(false, 'CREATOR-CLI', '', 'log') // Empty line - + // Create a temporary bot instance to access browser creation const bot = new MicrosoftRewardsBot(false) const browserFactory = new Browser(bot) - + try { // Create browser (non-headless for user interaction with CAPTCHA) log(false, 'CREATOR-CLI', 'Opening browser (required for CAPTCHA solving)...', 'log') - + // Create empty proxy config (no proxy for account creation) const emptyProxy = { proxyAxios: false, @@ -72,44 +72,44 @@ async function main(): Promise { password: '', username: '' } - + const browserContext = await browserFactory.createBrowser(emptyProxy, 'account-creator') - + log(false, 'CREATOR-CLI', '✅ Browser opened successfully', 'log', 'green') - + // Create account const creator = new AccountCreator(referralUrl, recoveryEmail, autoAccept) const result = await creator.create(browserContext) - + if (result) { // Success banner log(false, 'CREATOR-CLI', '', 'log') // Empty line log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'green') log(false, 'CREATOR-CLI', '✅ ACCOUNT CREATED SUCCESSFULLY!', 'log', 'green') log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'green') - + // Display account details log(false, 'CREATOR-CLI', `📧 Email: ${result.email}`, 'log', 'cyan') log(false, 'CREATOR-CLI', `🔐 Password: ${result.password}`, 'log', 'cyan') log(false, 'CREATOR-CLI', `👤 Name: ${result.firstName} ${result.lastName}`, 'log', 'cyan') log(false, 'CREATOR-CLI', `🎂 Birthdate: ${result.birthdate.day}/${result.birthdate.month}/${result.birthdate.year}`, 'log', 'cyan') - + if (result.referralUrl) { log(false, 'CREATOR-CLI', '🔗 Referral: Linked', 'log', 'green') } - + log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'green') log(false, 'CREATOR-CLI', '💾 Account details saved to accounts-created/ directory', 'log', 'green') log(false, 'CREATOR-CLI', '='.repeat(60), 'log', 'green') log(false, 'CREATOR-CLI', '', 'log') // Empty line - + // Keep browser open - don't close log(false, 'CREATOR-CLI', '✅ Account creation complete! Browser will remain open.', 'log', 'green') log(false, 'CREATOR-CLI', 'You can now use the account or close the browser manually.', 'log', 'cyan') log(false, 'CREATOR-CLI', 'Press Ctrl+C to exit the script.', 'log', 'yellow') - + // Keep process alive indefinitely - await new Promise(() => {}) // Never resolves + await new Promise(() => { }) // Never resolves } else { // Failure log(false, 'CREATOR-CLI', '', 'log') // Empty line @@ -117,11 +117,11 @@ async function main(): Promise { log(false, 'CREATOR-CLI', '❌ ACCOUNT CREATION FAILED', 'error') log(false, 'CREATOR-CLI', '='.repeat(60), 'error') log(false, 'CREATOR-CLI', '', 'log') // Empty line - + await browserContext.close() process.exit(1) } - + } catch (error) { const msg = error instanceof Error ? error.message : String(error) log(false, 'CREATOR-CLI', '', 'log') // Empty line diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index ce7a816..c776eb8 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -4,8 +4,8 @@ import playwright, { BrowserContext } from 'rebrowser-playwright' import { MicrosoftRewardsBot } from '../index' import { AccountProxy } from '../interface/Account' -import { loadSessionData, saveFingerprintData } from '../util/Load' -import { updateFingerprintUserAgent } from '../util/UserAgent' +import { updateFingerprintUserAgent } from '../util/browser/UserAgent' +import { loadSessionData, saveFingerprintData } from '../util/state/Load' class Browser { private bot: MicrosoftRewardsBot @@ -22,7 +22,7 @@ class Browser { this.bot.log(this.bot.isMobile, 'BROWSER', 'Auto-installing Chromium...', 'log') execSync('npx playwright install chromium', { stdio: 'ignore', timeout: 120000 }) this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium installed successfully', 'log') - } catch (e) { + } catch (e) { // FIXED: Improved error logging (no longer silent) const errorMsg = e instanceof Error ? e.message : String(e) this.bot.log(this.bot.isMobile, 'BROWSER', `Auto-install failed: ${errorMsg}`, 'warn') @@ -33,13 +33,13 @@ class Browser { try { const envForceHeadless = process.env.FORCE_HEADLESS === '1' const headless = envForceHeadless ? true : (this.bot.config.browser?.headless ?? false) - + const engineName = 'chromium' this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) const proxyConfig = this.buildPlaywrightProxy(proxy) const isLinux = process.platform === 'linux' - + // Base arguments for stability const baseArgs = [ '--no-sandbox', @@ -49,7 +49,7 @@ class Browser { '--ignore-certificate-errors-spki-list', '--ignore-ssl-errors' ] - + // Linux stability fixes const linuxStabilityArgs = isLinux ? [ '--disable-dev-shm-usage', @@ -88,10 +88,10 @@ class Browser { try { context.on('page', async (page) => { try { - const viewport = this.bot.isMobile + const viewport = this.bot.isMobile ? { width: 390, height: 844 } : { width: 1280, height: 800 } - + await page.setViewportSize(viewport) // Standard styling @@ -106,13 +106,13 @@ class Browser { } ` document.documentElement.appendChild(style) - } catch {/* ignore */} + } catch {/* ignore */ } }) - } catch (e) { + } catch (e) { 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 warning: ${e instanceof Error ? e.message : String(e)}`, 'warn') } diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 51b113f..2250b52 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -8,7 +8,7 @@ import { AppUserData } from '../interface/AppUserData' import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData' import { EarnablePoints } from '../interface/Points' import { QuizData } from '../interface/QuizData' -import { saveSessionData } from '../util/Load' +import { saveSessionData } from '../util/state/Load' export default class BrowserFunc { @@ -29,12 +29,12 @@ export default class BrowserFunc { 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(() => '')) || '' @@ -43,7 +43,7 @@ export default class BrowserFunc { /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') @@ -54,7 +54,7 @@ export default class BrowserFunc { const errorMsg = error instanceof Error ? error.message : String(error) this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${errorMsg}`, 'warn') } - + return false } @@ -90,7 +90,7 @@ export default class BrowserFunc { if (isSuspended) { throw new Error('Account has been suspended!') } - + // 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') } @@ -133,10 +133,10 @@ export default class BrowserFunc { this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page') await this.goHome(target) } - + // Reload with retry await this.reloadPageWithRetry(target, 2) - + // Wait for the more-activities element to ensure page is fully loaded await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((error) => { // Continuing is intentional: page may still be functional even if this specific element is missing @@ -149,7 +149,7 @@ export default class BrowserFunc { if (!scriptContent) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn') - + // Force a navigation retry once before failing hard await this.goHome(target) await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((error) => { @@ -157,9 +157,9 @@ export default class BrowserFunc { this.bot.log(this.bot.isMobile, 'BROWSER-FUNC', `Dashboard recovery load failed: ${errorMsg}`, 'warn') }) await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM) - + scriptContent = await this.extractDashboardScript(target) - + if (!scriptContent) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error') throw new Error('Dashboard data not found within script - check page structure') @@ -192,14 +192,14 @@ export default class BrowserFunc { const startTime = Date.now() const MAX_TOTAL_TIME_MS = 30000 // 30 seconds max total let lastError: unknown = null - + for (let attempt = 1; attempt <= maxAttempts; attempt++) { // Check global timeout if (Date.now() - startTime > MAX_TOTAL_TIME_MS) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload retry exceeded total timeout (${MAX_TOTAL_TIME_MS}ms)`, 'warn') break } - + try { await page.reload({ waitUntil: 'domcontentloaded' }) await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM) @@ -212,7 +212,7 @@ export default class BrowserFunc { if (msg.includes('has been closed')) { if (attempt === 1) { 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 {/* ignore */ } } else { break } @@ -222,7 +222,7 @@ export default class BrowserFunc { } } } - + if (lastError) throw lastError } @@ -233,12 +233,12 @@ export default class BrowserFunc { return await page.evaluate(() => { const scripts = Array.from(document.querySelectorAll('script')) const dashboardPatterns = ['var dashboard', 'dashboard=', 'dashboard :'] - + const targetScript = scripts.find(script => { const text = script.innerText return text && dashboardPatterns.some(pattern => text.includes(pattern)) }) - + return targetScript?.innerText || null }) } @@ -265,19 +265,19 @@ export default class BrowserFunc { if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { continue } - + const parsed = JSON.parse(jsonStr) - + // Enhanced validation: check structure and type if (typeof parsed !== 'object' || parsed === null) { continue } - + // Validate essential dashboard properties exist if (!parsed.userStatus || typeof parsed.userStatus !== 'object') { continue } - + // Successfully validated dashboard structure return parsed } catch (e) { @@ -401,7 +401,7 @@ export default class BrowserFunc { const checkInDay = parseInt(item.attributes.progress ?? '', 10) % 7 const today = new Date() const lastUpdated = new Date(item.attributes.last_updated ?? '') - + if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) { points.checkIn = parseInt(item.attributes['day_' + (checkInDay + 1) + '_points'] ?? '', 10) } @@ -493,10 +493,10 @@ export default class BrowserFunc { .map(el => $(el).text()) .filter(t => t.length > 0) .map(t => t.substring(0, 100)) - + this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error') this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn') - + this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error') throw new Error('Script containing quiz data not found - check page structure') } @@ -545,10 +545,10 @@ export default class BrowserFunc { const html = await page.content() const $ = load(html) - const element = $('.offer-cta').toArray().find((x: unknown) => { - const el = x as { attribs?: { href?: string } } - return !!el.attribs?.href?.includes(activity.offerId) - }) + const element = $('.offer-cta').toArray().find((x: unknown) => { + const el = x as { attribs?: { href?: string } } + return !!el.attribs?.href?.includes(activity.offerId) + }) if (element) { selector = `a[href*="${element.attribs.href}"]` } diff --git a/src/browser/BrowserUtil.ts b/src/browser/BrowserUtil.ts index ac097b1..901774f 100644 --- a/src/browser/BrowserUtil.ts +++ b/src/browser/BrowserUtil.ts @@ -1,7 +1,7 @@ import { load } from 'cheerio' import { Page } from 'rebrowser-playwright' import { MicrosoftRewardsBot } from '../index' -import { logError } from '../util/Logger' +import { logError } from '../util/notifications/Logger' type DismissButton = { selector: string; label: string; isXPath?: boolean } @@ -145,14 +145,14 @@ export default class BrowserUtil { private async dismissTermsUpdateDialog(page: Page): Promise { try { const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS - + // Check if terms update page is present const titleById = page.locator(titleId) const titleByText = page.locator('h1').filter({ hasText: titleText }) - + const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) || - await titleByText.first().isVisible({ timeout: 200 }).catch(() => false) - + await titleByText.first().isVisible({ timeout: 200 }).catch(() => false) + if (!hasTitle) return 0 // Click the Next button @@ -199,9 +199,9 @@ export default class BrowserUtil { const $ = load(html) const isNetworkError = $('body.neterror').length - const hasHttp400Error = html.includes('HTTP ERROR 400') || - html.includes('This page isn\'t working') || - html.includes('This page is not working') + const hasHttp400Error = html.includes('HTTP ERROR 400') || + html.includes('This page isn\'t working') || + html.includes('This page is not working') if (isNetworkError || hasHttp400Error) { const errorType = hasHttp400Error ? 'HTTP 400' : 'network error' diff --git a/src/constants.ts b/src/constants.ts index 6457550..48e7cfe 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,7 +18,7 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: num const parsed = Number(raw) if (!Number.isFinite(parsed)) { queueMicrotask(() => { - import('./util/Logger').then(({ log }) => { + import('./util/notifications/Logger').then(({ log }) => { log('main', 'CONSTANTS', `Invalid ${key}="${raw}" (not a finite number), using default: ${defaultValue}`, 'warn') }).catch(() => { process.stderr.write(`[Constants] Invalid ${key}="${raw}" (not a finite number), using default: ${defaultValue}\n`) @@ -29,7 +29,7 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: num if (parsed < min || parsed > max) { queueMicrotask(() => { - import('./util/Logger').then(({ log }) => { + import('./util/notifications/Logger').then(({ log }) => { log('main', 'CONSTANTS', `${key}=${parsed} out of range [${min}, ${max}], using default: ${defaultValue}`, 'warn') }).catch(() => { process.stderr.write(`[Constants] ${key}=${parsed} out of range [${min}, ${max}], using default: ${defaultValue}\n`) diff --git a/src/dashboard/BotController.ts b/src/dashboard/BotController.ts index 251aeeb..b131de9 100644 --- a/src/dashboard/BotController.ts +++ b/src/dashboard/BotController.ts @@ -1,6 +1,6 @@ import type { MicrosoftRewardsBot } from '../index' -import { log as botLog } from '../util/Logger' -import { getErrorMessage } from '../util/Utils' +import { getErrorMessage } from '../util/core/Utils' +import { log as botLog } from '../util/notifications/Logger' import { dashboardState } from './state' export class BotController { @@ -14,7 +14,7 @@ export class BotController { private log(message: string, level: 'log' | 'warn' | 'error' = 'log'): void { botLog('main', 'BOT-CONTROLLER', message, level) - + dashboardState.addLog({ timestamp: new Date().toISOString(), level, @@ -29,7 +29,7 @@ export class BotController { if (this.botInstance) { return { success: false, error: 'Bot is already running' } } - + if (this.isStarting) { return { success: false, error: 'Bot is currently starting, please wait' } } @@ -39,7 +39,7 @@ export class BotController { this.log('🚀 Starting bot...', 'log') const { MicrosoftRewardsBot } = await import('../index') - + this.botInstance = new MicrosoftRewardsBot(false) this.startTime = new Date() dashboardState.setRunning(true) @@ -49,10 +49,10 @@ export class BotController { void (async () => { try { this.log('✓ Bot initialized, starting execution...', 'log') - + await this.botInstance!.initialize() await this.botInstance!.run() - + this.log('✓ Bot completed successfully', 'log') } catch (error) { this.log(`Bot error: ${getErrorMessage(error)}`, 'error') @@ -81,7 +81,7 @@ export class BotController { try { this.log('🛑 Stopping bot...', 'warn') this.log('⚠ Note: Bot will complete current task before stopping', 'warn') - + this.cleanup() return { success: true } @@ -95,14 +95,14 @@ export class BotController { public async restart(): Promise<{ success: boolean; error?: string; pid?: number }> { this.log('🔄 Restarting bot...', 'log') - + const stopResult = this.stop() if (!stopResult.success && stopResult.error !== 'Bot is not running') { return { success: false, error: `Failed to stop: ${stopResult.error}` } } - + await this.wait(2000) - + return await this.start() } diff --git a/src/dashboard/routes.ts b/src/dashboard/routes.ts index 95d82a3..ace7d6a 100644 --- a/src/dashboard/routes.ts +++ b/src/dashboard/routes.ts @@ -1,7 +1,7 @@ import { Request, Response, Router } from 'express' import fs from 'fs' import path from 'path' -import { getConfigPath, loadAccounts, loadConfig } from '../util/Load' +import { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load' import { botController } from './BotController' import { dashboardState } from './state' @@ -100,7 +100,7 @@ apiRouter.get('/config', (_req: Request, res: Response) => { try { const config = loadConfig() const safe = JSON.parse(JSON.stringify(config)) - + // Mask sensitive data if (safe.webhook?.url) safe.webhook.url = maskUrl(safe.webhook.url) if (safe.conclusionWebhook?.url) safe.conclusionWebhook.url = maskUrl(safe.conclusionWebhook.url) @@ -117,7 +117,7 @@ apiRouter.post('/config', (req: Request, res: Response): void => { try { const newConfig = req.body const configPath = getConfigPath() - + if (!configPath || !fs.existsSync(configPath)) { res.status(404).json({ error: 'Config file not found' }) return @@ -146,7 +146,7 @@ apiRouter.post('/start', async (_req: Request, res: Response): Promise => } const result = await botController.start() - + if (result.success) { sendSuccess(res, { message: 'Bot started successfully', pid: result.pid }) } else { @@ -161,7 +161,7 @@ apiRouter.post('/start', async (_req: Request, res: Response): Promise => apiRouter.post('/stop', (_req: Request, res: Response): void => { try { const result = botController.stop() - + if (result.success) { sendSuccess(res, { message: 'Bot stopped successfully' }) } else { @@ -176,7 +176,7 @@ apiRouter.post('/stop', (_req: Request, res: Response): void => { apiRouter.post('/restart', async (_req: Request, res: Response): Promise => { try { const result = await botController.restart() - + if (result.success) { sendSuccess(res, { message: 'Bot restarted successfully', pid: result.pid }) } else { @@ -194,7 +194,7 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => { const totalPoints = accounts.reduce((sum, a) => sum + (a.points || 0), 0) const accountsWithErrors = accounts.filter(a => a.errors && a.errors.length > 0).length const avgPoints = accounts.length > 0 ? Math.round(totalPoints / accounts.length) : 0 - + res.json({ totalAccounts: accounts.length, totalPoints, @@ -218,14 +218,14 @@ apiRouter.get('/account/:email', (req: Request, res: Response): void => { res.status(400).json({ error: 'Email parameter required' }) return } - + const account = dashboardState.getAccount(email) - + if (!account) { res.status(404).json({ error: 'Account not found' }) return } - + res.json(account) } catch (error) { res.status(500).json({ error: getErr(error) }) @@ -240,19 +240,19 @@ apiRouter.post('/account/:email/reset', (req: Request, res: Response): void => { res.status(400).json({ error: 'Email parameter required' }) return } - + const account = dashboardState.getAccount(email) - + if (!account) { res.status(404).json({ error: 'Account not found' }) return } - + dashboardState.updateAccount(email, { status: 'idle', errors: [] }) - + res.json({ success: true }) } catch (error) { res.status(500).json({ error: getErr(error) }) @@ -263,10 +263,10 @@ apiRouter.post('/account/:email/reset', (req: Request, res: Response): void => { function maskUrl(url: string): string { try { const parsed = new URL(url) - const maskedHost = parsed.hostname.length > 6 + const maskedHost = parsed.hostname.length > 6 ? `${parsed.hostname.slice(0, 3)}***${parsed.hostname.slice(-3)}` : '***' - const maskedPath = parsed.pathname.length > 5 + const maskedPath = parsed.pathname.length > 5 ? `${parsed.pathname.slice(0, 3)}***` : '***' return `${parsed.protocol}//${maskedHost}${maskedPath}` diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 88fda40..fad0aab 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -3,7 +3,7 @@ import fs from 'fs' import { createServer } from 'http' import path from 'path' import { WebSocket, WebSocketServer } from 'ws' -import { log as botLog } from '../util/Logger' +import { log as botLog } from '../util/notifications/Logger' import { apiRouter } from './routes' import { DashboardLog, dashboardState } from './state' @@ -41,7 +41,7 @@ export class DashboardServer { private setupMiddleware(): void { this.app.use(express.json()) - + // Disable caching for all static files this.app.use((req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private') @@ -49,7 +49,7 @@ export class DashboardServer { res.set('Expires', '0') next() }) - + this.app.use('/assets', express.static(path.join(__dirname, '../../assets'), { etag: false, maxAge: 0 @@ -62,7 +62,7 @@ export class DashboardServer { private setupRoutes(): void { this.app.use('/api', apiRouter) - + // Health check this.app.get('/health', (_req, res) => { res.json({ status: 'ok', uptime: process.uptime() }) @@ -71,12 +71,12 @@ export class DashboardServer { // Serve dashboard UI this.app.get('/', (_req, res) => { const indexPath = path.join(__dirname, '../../public/index.html') - + // Force no cache on HTML files res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private') res.set('Pragma', 'no-cache') res.set('Expires', '0') - + if (fs.existsSync(indexPath)) { res.sendFile(indexPath) } else { @@ -117,9 +117,9 @@ export class DashboardServer { const recentLogs = dashboardState.getLogs(100) const status = dashboardState.getStatus() const accounts = dashboardState.getAccounts() - - ws.send(JSON.stringify({ - type: 'init', + + ws.send(JSON.stringify({ + type: 'init', data: { logs: recentLogs, status, @@ -135,7 +135,7 @@ export class DashboardServer { // eslint-disable-next-line @typescript-eslint/no-var-requires const loggerModule = require('../util/Logger') as { log: typeof botLog } const originalLog = loggerModule.log - + loggerModule.log = ( isMobile: boolean | 'main', title: string, @@ -145,7 +145,7 @@ export class DashboardServer { ) => { // Call original log function const result = originalLog(isMobile, title, message, type, color as keyof typeof import('chalk')) - + // Create log entry for dashboard const logEntry: DashboardLog = { timestamp: new Date().toISOString(), @@ -154,14 +154,14 @@ export class DashboardServer { title, message } - + // Add to dashboard state and broadcast dashboardState.addLog(logEntry) this.broadcastUpdate('log', { log: logEntry }) - + return result } - + dashLog('Bot log interception active') } diff --git a/src/flows/DesktopFlow.ts b/src/flows/DesktopFlow.ts index 70ad0af..d244c29 100644 --- a/src/flows/DesktopFlow.ts +++ b/src/flows/DesktopFlow.ts @@ -12,7 +12,7 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' -import { closeBrowserSafely, createBrowserInstance } from '../util/BrowserFactory' +import { closeBrowserSafely, createBrowserInstance } from '../util/browser/BrowserFactory' import { handleCompromisedMode } from './FlowUtils' export interface DesktopFlowResult { diff --git a/src/flows/FlowUtils.ts b/src/flows/FlowUtils.ts index b9becf1..b338492 100644 --- a/src/flows/FlowUtils.ts +++ b/src/flows/FlowUtils.ts @@ -4,7 +4,7 @@ */ import type { MicrosoftRewardsBot } from '../index' -import { saveSessionData } from '../util/Load' +import { saveSessionData } from '../util/state/Load' /** * Handle compromised/security check mode for an account @@ -27,7 +27,7 @@ export async function handleCompromisedMode( isMobile: boolean ): Promise<{ keepBrowserOpen: boolean }> { const flowContext = isMobile ? 'MOBILE-FLOW' : 'DESKTOP-FLOW' - + bot.log( isMobile, flowContext, @@ -35,10 +35,10 @@ export async function handleCompromisedMode( 'warn', 'yellow' ) - + // Send security alert webhook try { - const { ConclusionWebhook } = await import('../util/ConclusionWebhook') + const { ConclusionWebhook } = await import('../util/notifications/ConclusionWebhook') await ConclusionWebhook( bot.config, isMobile ? '🔐 Security Check (Mobile)' : '🔐 Security Check', @@ -50,7 +50,7 @@ export async function handleCompromisedMode( const errorMsg = error instanceof Error ? error.message : String(error) bot.log(isMobile, flowContext, `Failed to send security webhook: ${errorMsg}`, 'warn') } - + // Save session for convenience (non-critical) try { await saveSessionData(bot.config.sessionPath, bot.homePage.context(), account, isMobile) @@ -58,6 +58,6 @@ export async function handleCompromisedMode( const errorMsg = error instanceof Error ? error.message : String(error) bot.log(isMobile, flowContext, `Failed to save session: ${errorMsg}`, 'warn') } - + return { keepBrowserOpen: true } } diff --git a/src/flows/MobileFlow.ts b/src/flows/MobileFlow.ts index fc9b1f2..39b6746 100644 --- a/src/flows/MobileFlow.ts +++ b/src/flows/MobileFlow.ts @@ -13,8 +13,8 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' -import { closeBrowserSafely, createBrowserInstance } from '../util/BrowserFactory' -import { MobileRetryTracker } from '../util/MobileRetryTracker' +import { closeBrowserSafely, createBrowserInstance } from '../util/browser/BrowserFactory' +import { MobileRetryTracker } from '../util/state/MobileRetryTracker' import { handleCompromisedMode } from './FlowUtils' export interface MobileFlowResult { diff --git a/src/flows/SummaryReporter.ts b/src/flows/SummaryReporter.ts index 28d6610..0e1af35 100644 --- a/src/flows/SummaryReporter.ts +++ b/src/flows/SummaryReporter.ts @@ -10,10 +10,10 @@ */ import type { Config } from '../interface/Config' -import { ConclusionWebhook } from '../util/ConclusionWebhook' -import { JobState } from '../util/JobState' -import { log } from '../util/Logger' -import { Ntfy } from '../util/Ntfy' +import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook' +import { log } from '../util/notifications/Logger' +import { Ntfy } from '../util/notifications/Ntfy' +import { JobState } from '../util/state/JobState' export interface AccountResult { email: string @@ -54,7 +54,7 @@ export class SummaryReporter { const minutes = Math.floor((duration % 3600) / 60) const seconds = duration % 60 - const durationText = hours > 0 + const durationText = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` @@ -67,7 +67,7 @@ export class SummaryReporter { for (const account of summary.accounts) { const status = account.errors?.length ? '❌' : '✅' description += `${status} ${account.email}: ${account.pointsEarned} points (${Math.round(account.runDuration / 1000)}s)\n` - + if (account.errors?.length) { description += ` ⚠️ ${account.errors[0]}\n` } @@ -95,7 +95,7 @@ export class SummaryReporter { try { const message = `Collected ${summary.totalPoints} points across ${summary.accounts.length} account(s). Success: ${summary.successCount}, Failed: ${summary.failureCount}` - + await Ntfy(message, summary.failureCount > 0 ? 'warn' : 'log') } catch (error) { log('main', 'SUMMARY', `Failed to send Ntfy notification: ${error instanceof Error ? error.message : String(error)}`, 'error') @@ -109,7 +109,7 @@ export class SummaryReporter { try { const day = summary.endTime.toISOString().split('T')?.[0] if (!day) return - + for (const account of summary.accounts) { this.jobState.markAccountComplete( account.email, @@ -133,12 +133,12 @@ export class SummaryReporter { log('main', 'SUMMARY', '═'.repeat(80)) log('main', 'SUMMARY', '📊 EXECUTION SUMMARY') log('main', 'SUMMARY', '═'.repeat(80)) - + const duration = Math.round((summary.endTime.getTime() - summary.startTime.getTime()) / 1000) log('main', 'SUMMARY', `⏱️ Duration: ${Math.floor(duration / 60)}m ${duration % 60}s`) log('main', 'SUMMARY', `📈 Total Points Collected: ${summary.totalPoints}`) log('main', 'SUMMARY', `✅ Successful Accounts: ${summary.successCount}/${summary.accounts.length}`) - + if (summary.failureCount > 0) { log('main', 'SUMMARY', `❌ Failed Accounts: ${summary.failureCount}`, 'warn') } @@ -150,10 +150,10 @@ export class SummaryReporter { for (const account of summary.accounts) { const status = account.errors?.length ? '❌ FAILED' : '✅ SUCCESS' const duration = Math.round(account.runDuration / 1000) - + log('main', 'SUMMARY', `${status} | ${account.email}`) log('main', 'SUMMARY', ` Points: ${account.pointsEarned} | Duration: ${duration}s`) - + if (account.errors?.length) { log('main', 'SUMMARY', ` Error: ${account.errors[0]}`, 'error') } diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 3b1e6fe..16dd9c2 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -5,11 +5,11 @@ import readline from 'readline' import { MicrosoftRewardsBot } from '../index' import { OAuth } from '../interface/OAuth' -import { saveSessionData } from '../util/Load' -import { logError } from '../util/Logger' -import { LoginState, LoginStateDetector } from '../util/LoginStateDetector' -import { Retry } from '../util/Retry' -import { generateTOTP } from '../util/Totp' +import { Retry } from '../util/core/Retry' +import { logError } from '../util/notifications/Logger' +import { generateTOTP } from '../util/security/Totp' +import { saveSessionData } from '../util/state/Load' +import { LoginState, LoginStateDetector } from '../util/validation/LoginStateDetector' // ------------------------------- // REFACTORING NOTE (1700+ lines) @@ -100,7 +100,7 @@ export class Login { private lastTotpSubmit = 0 private totpAttempts = 0 - constructor(bot: MicrosoftRewardsBot) { + constructor(bot: MicrosoftRewardsBot) { this.bot = bot this.cleanupCompromisedInterval() } @@ -110,35 +110,35 @@ export class Login { * Eliminates duplicate navigation code throughout the file */ private async navigateWithRetry( - page: Page, - url: string, + page: Page, + url: string, context: string, maxAttempts = 3 ): Promise<{ success: boolean; recoveryUsed: boolean }> { const isLinux = process.platform === 'linux' const navigationTimeout = isLinux ? DEFAULT_TIMEOUTS.navigationTimeoutLinux : DEFAULT_TIMEOUTS.navigationTimeout - + let navigationSucceeded = false let recoveryUsed = false let attempts = 0 - + while (!navigationSucceeded && attempts < maxAttempts) { attempts++ try { - await page.goto(url, { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navigationTimeout }) navigationSucceeded = true } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) - + // Chrome-error recovery pattern if (errorMsg.includes('chrome-error://chromewebdata/')) { this.bot.log(this.bot.isMobile, context, `Navigation interrupted by chrome-error (attempt ${attempts}/${maxAttempts}), attempting recovery...`, 'warn') - + await this.bot.utils.wait(DEFAULT_TIMEOUTS.long) - + try { await page.reload({ waitUntil: 'domcontentloaded', timeout: navigationTimeout }) navigationSucceeded = true @@ -167,7 +167,7 @@ export class Login { } } } - + return { success: navigationSucceeded, recoveryUsed } } @@ -175,7 +175,7 @@ export class Login { async login(page: Page, email: string, password: string, totpSecret?: string) { try { this.cleanupCompromisedInterval() - + this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process') this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined this.lastTotpSubmit = 0 @@ -199,19 +199,19 @@ export class Login { 'https://www.bing.com/rewards/dashboard', 'LOGIN' ) - + if (!navigationSucceeded) { throw new Error('Failed to navigate to dashboard after multiple attempts') } - + // Only check for HTTP 400 if recovery was NOT used (to avoid double reload) if (!recoveryUsed) { await this.bot.utils.wait(DEFAULT_TIMEOUTS.fastPoll) const content = await page.content().catch(() => '') - const hasHttp400 = content.includes('HTTP ERROR 400') || - content.includes('This page isn\'t working') || - content.includes('This page is not working') - + const hasHttp400 = content.includes('HTTP ERROR 400') || + content.includes('This page isn\'t working') || + content.includes('This page is not working') + if (hasHttp400) { this.bot.log(this.bot.isMobile, 'LOGIN', 'HTTP 400 detected in content, reloading...', 'warn') const isLinux = process.platform === 'linux' @@ -220,15 +220,15 @@ export class Login { await this.bot.utils.wait(DEFAULT_TIMEOUTS.medium) } } - + await this.disableFido(page) - + const [reloadResult, totpResult, portalCheck] = await Promise.allSettled([ this.bot.browser.utils.reloadBadPage(page), this.tryAutoTotp(page, 'initial landing'), page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 3000 }) ]) - + // Log any failures for debugging (non-critical) if (reloadResult.status === 'rejected') { this.bot.log(this.bot.isMobile, 'LOGIN', `Reload check failed (non-critical): ${reloadResult.reason}`, 'warn') @@ -236,7 +236,7 @@ export class Login { if (totpResult.status === 'rejected') { this.bot.log(this.bot.isMobile, 'LOGIN', `Auto-TOTP check failed (non-critical): ${totpResult.reason}`, 'warn') } - + await this.checkAccountLocked(page) const alreadyAuthenticated = portalCheck.status === 'fulfilled' @@ -250,7 +250,7 @@ export class Login { if (needsBingVerification) { await this.verifyBingContext(page) } - + await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile) this.bot.log(this.bot.isMobile, 'LOGIN', 'Login complete') this.currentTotpSecret = undefined @@ -270,7 +270,7 @@ export class Login { this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined this.lastTotpSubmit = 0 this.totpAttempts = 0 - + await this.disableFido(page) const url = new URL(this.authBaseUrl) url.searchParams.set('response_type', 'code') @@ -287,21 +287,21 @@ export class Login { url.href, 'LOGIN-APP' ) - + if (!navigationSucceeded) { throw new Error('Failed to navigate to OAuth page after multiple attempts') } - + if (!recoveryUsed) { await this.bot.utils.wait(DEFAULT_TIMEOUTS.fastPoll) const content = await page.content().catch((err) => { this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Failed to get page content for HTTP 400 check: ${err}`, 'warn') return '' }) - const hasHttp400 = content.includes('HTTP ERROR 400') || - content.includes('This page isn\'t working') || - content.includes('This page is not working') - + const hasHttp400 = content.includes('HTTP ERROR 400') || + content.includes('This page isn\'t working') || + content.includes('This page is not working') + if (hasHttp400) { this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'HTTP 400 detected, reloading...', 'warn') const isLinux = process.platform === 'linux' @@ -315,30 +315,30 @@ export class Login { let code = '' let lastLogTime = start let checkCount = 0 - + while (Date.now() - start < DEFAULT_TIMEOUTS.oauthMaxMs) { checkCount++ - + const u = new URL(page.url()) if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') { code = u.searchParams.get('code') || '' if (code) break } - + if (checkCount % 3 === 0) { await Promise.allSettled([ this.handlePasskeyPrompts(page, 'oauth'), this.tryAutoTotp(page, 'mobile-oauth') ]) } - + const now = Date.now() if (now - lastLogTime > 30000) { const elapsed = Math.round((now - start) / 1000) this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Waiting for OAuth code... (${elapsed}s, URL: ${u.hostname}${u.pathname})`, 'warn') lastLogTime = now } - + const pollDelay = Date.now() - start < 30000 ? 800 : 1500 await this.bot.utils.wait(pollDelay) } @@ -348,7 +348,7 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s. Current URL: ${currentUrl}`, 'error') throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s`) } - + this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`) const form = new URLSearchParams() @@ -361,16 +361,16 @@ export class Login { 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' + return status === 502 || status === 503 || status === 504 || + err.code === 'ECONNRESET' || + err.code === 'ETIMEDOUT' } - const req: AxiosRequestConfig = { - url: this.tokenUrl, - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - data: form.toString() + const req: AxiosRequestConfig = { + url: this.tokenUrl, + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: form.toString() } const retry = new Retry(this.bot.config.retryPolicy) @@ -380,7 +380,7 @@ export class Login { isRetryable ) 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`) this.currentTotpSecret = undefined return data.access_token } catch (error) { @@ -410,19 +410,19 @@ export class Login { homeUrl, 'LOGIN' ) - + if (!navigationSucceeded) return false - + await page.waitForLoadState('domcontentloaded').catch(logError('LOGIN', 'DOMContentLoaded timeout', this.bot.isMobile)) - + // Only check HTTP 400 if recovery was NOT used if (!recoveryUsed) { await this.bot.utils.wait(DEFAULT_TIMEOUTS.fastPoll) const content = await page.content().catch(() => '') - const hasHttp400 = content.includes('HTTP ERROR 400') || - content.includes('This page isn\'t working') || - content.includes('This page is not working') - + const hasHttp400 = content.includes('HTTP ERROR 400') || + content.includes('This page isn\'t working') || + content.includes('This page is not working') + if (hasHttp400) { this.bot.log(this.bot.isMobile, 'LOGIN', 'HTTP 400 on session check, reloading...', 'warn') const isLinux = process.platform === 'linux' @@ -436,7 +436,7 @@ export class Login { // IMPROVED: Increased timeout from 3.5s to 8s for slow connections let portalSelector = await this.waitForRewardsRoot(page, 8000) - + // IMPROVED: Retry once if initial check failed if (!portalSelector) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not detected (8s), retrying once...', 'warn') @@ -444,7 +444,7 @@ export class Login { await this.bot.browser.utils.reloadBadPage(page) portalSelector = await this.waitForRewardsRoot(page, 5000) } - + if (portalSelector) { // Additional validation: make sure we're not just on the page but actually logged in // Check if we're redirected to login @@ -453,7 +453,7 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Detected redirect to login page - session not valid', 'warn') return false } - + this.bot.log(this.bot.isMobile, 'LOGIN', `✅ Existing session still valid (${portalSelector}) — saved 2-3 minutes!`) await this.checkAccountLocked(page) return true @@ -473,14 +473,14 @@ export class Login { if (currentUrl.includes('login.live.com') || currentUrl.includes('login.microsoftonline.com')) { await this.handlePasskeyPrompts(page, 'main') } - } catch {/* ignore reuse errors and continue with full login */} + } catch {/* ignore reuse errors and continue with full login */ } return false } private async performLoginFlow(page: Page, email: string, password: string) { // Step 1: Input email await this.inputEmail(page, email) - + // Step 2: Wait for transition to password page (VALIDATION PROGRESSIVE) this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for password page transition...') const passwordPageReached = await LoginStateDetector.waitForAnyState( @@ -488,14 +488,14 @@ export class Login { [LoginState.PasswordPage, LoginState.TwoFactorRequired, LoginState.LoggedIn], 8000 ) - + if (passwordPageReached === LoginState.LoggedIn) { // Double-check: verify we're actually on rewards portal with activities const actuallyLoggedIn = await page.locator('#more-activities, html[data-role-name*="RewardsPortal"]') .first() .isVisible({ timeout: 2000 }) .catch(() => false) - + if (actuallyLoggedIn) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Already authenticated after email (fast path)') return @@ -504,33 +504,33 @@ export class Login { // Continue to password entry } } - + if (!passwordPageReached) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Password page not reached after 8s, continuing anyway...', 'warn') } else if (passwordPageReached !== LoginState.LoggedIn) { this.bot.log(this.bot.isMobile, 'LOGIN', `Transitioned to state: ${passwordPageReached}`) } - + await this.bot.utils.wait(500) await this.bot.browser.utils.reloadBadPage(page) - + // Step 3: Recovery mismatch check await this.tryRecoveryMismatchCheck(page, email) if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') { - this.bot.log(this.bot.isMobile,'LOGIN','Recovery mismatch detected – stopping before password entry','warn') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery mismatch detected – stopping before password entry', 'warn') return } - + // Step 4: Try switching to password if needed await this.switchToPasswordLink(page) - + // Step 5: Input password or handle 2FA await this.inputPasswordOr2FA(page, password) if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') { this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn') return } - + // Step 6: Final checks await this.checkAccountLocked(page) await this.awaitRewardsPortal(page) @@ -541,25 +541,25 @@ export class Login { // Check for passkey prompts first await this.handlePasskeyPrompts(page, 'main') await this.bot.utils.wait(500) // Increased from 250ms - + // IMPROVEMENT: Wait for page to be fully ready before looking for email field // Silent catch justified: DOMContentLoaded may already be complete, which is fine - await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {}) + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { }) await this.bot.utils.wait(300) // Extra settling time if (await this.tryAutoTotp(page, 'pre-email check')) { await this.bot.utils.wait(1000) // Increased from 800ms } - + // IMPROVEMENT: More retries with better timing - let field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(()=>null) // Increased from 5000ms + let field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(() => null) // Increased from 5000ms if (!field) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not found (first attempt), retrying...', 'warn') - + const totpHandled = await this.tryAutoTotp(page, 'pre-email challenge') if (totpHandled) { await this.bot.utils.wait(1200) // Increased from 800ms - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(()=>null) + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 8000 }).catch(() => null) } } @@ -568,43 +568,43 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not found (second attempt), trying passkey/reload...', 'warn') await this.handlePasskeyPrompts(page, 'main') await this.bot.utils.wait(800) // Increased from 500ms - + // IMPROVEMENT: Try page reload if field still missing (common issue on first load) const content = await page.content().catch(() => '') if (content.length < 1000) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Page content too small, reloading...', 'warn') // Silent catch justified: Reload may timeout if page is slow, but we continue anyway - await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {}) + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => { }) await this.bot.utils.wait(1500) } - + const totpRetry = await this.tryAutoTotp(page, 'pre-email retry') if (totpRetry) { await this.bot.utils.wait(1200) // Increased from 800ms } - - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) + + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(() => null) if (!field && this.totpAttempts > 0) { await this.bot.utils.wait(2500) // Increased from 2000ms - field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) // Increased from 3000ms + field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(() => null) // Increased from 3000ms } if (!field) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present after all retries', 'error') throw new Error('Login form email field not found after multiple attempts') } } - - const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(()=>null) + + const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(() => null) if (!prefilled) { await page.fill(SELECTORS.emailInput, '') await page.fill(SELECTORS.emailInput, email) } else { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email prefilled') } - const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(()=>null) - if (next) { + const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null) + if (next) { await next.click().catch(e => this.bot.log(this.bot.isMobile, 'LOGIN', `Email submit click failed: ${e}`, 'warn')) - this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') } } @@ -612,12 +612,12 @@ export class Login { // Check for passkey prompts that might be blocking the password field await this.handlePasskeyPrompts(page, 'main') await this.bot.utils.wait(500) - + // Some flows require switching to password first - const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(()=>null) - if (switchBtn) { + const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(() => null) + if (switchBtn) { await switchBtn.click().catch(e => this.bot.log(this.bot.isMobile, 'LOGIN', `Switch to password failed: ${e}`, 'warn')) - await this.bot.utils.wait(1000) + await this.bot.utils.wait(1000) } // Early TOTP check - if totpSecret is configured, check for TOTP challenge before password @@ -630,14 +630,14 @@ export class Login { } // Rare flow: list of methods -> choose password - let passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(()=>null) + let passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(() => null) if (!passwordField) { // Maybe passkey prompt appeared - try handling it again await this.handlePasskeyPrompts(page, 'main') await this.bot.utils.wait(800) - passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(()=>null) + passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(() => null) } - + if (!passwordField) { const blocked = await this.detectSignInBlocked(page) if (blocked) return @@ -652,10 +652,10 @@ export class Login { await page.fill(SELECTORS.passwordInput, '') await page.fill(SELECTORS.passwordInput, password) - const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(()=>null) - if (submit) { + const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null) + if (submit) { await submit.click().catch(e => this.bot.log(this.bot.isMobile, 'LOGIN', `Password submit failed: ${e}`, 'warn')) - this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') } } @@ -666,10 +666,10 @@ export class Login { await this.bot.browser.utils.tryDismissAllMessages(page) await this.bot.utils.wait(500) - const usedTotp = await this.tryAutoTotp(page, '2FA initial step') + const usedTotp = await this.tryAutoTotp(page, '2FA initial step') if (usedTotp) return - const number = await this.fetchAuthenticatorNumber(page) + const number = await this.fetchAuthenticatorNumber(page) if (number) { await this.approveAuthenticator(page, number); return } await this.handleSMSOrTotp(page) } catch (e) { @@ -686,7 +686,7 @@ export class Login { if (this.bot.config.parallel) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Parallel mode: throttling authenticator push requests', 'log', 'yellow') for (let attempts = 0; attempts < 6; attempts++) { // max 6 minutes retry window - const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(()=>null) + const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(() => null) if (!resend) break await this.bot.utils.wait(60000) await resend.click().catch(logError('LOGIN', 'Resend click failed', this.bot.isMobile)) @@ -710,14 +710,14 @@ export class Login { return } catch { this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator code expired – refreshing') - const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(()=>null) + const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(() => null) if (retryBtn) await retryBtn.click().catch(logError('LOGIN-AUTH', 'Refresh button click failed', this.bot.isMobile)) const refreshed = await this.fetchAuthenticatorNumber(page) if (!refreshed) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Could not refresh authenticator code', 'warn'); return } numberToPress = refreshed } } - this.bot.log(this.bot.isMobile,'LOGIN','Authenticator approval loop exited (max cycles reached)','warn') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval loop exited (max cycles reached)', 'warn') } private async handleSMSOrTotp(page: Page) { @@ -728,7 +728,7 @@ export class Login { // Manual prompt with 120s timeout this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - + try { // FIXED: Add 120s timeout with proper cleanup to prevent memory leak let timeoutHandle: NodeJS.Timeout | undefined @@ -767,8 +767,8 @@ export class Login { // Other errors, just log and continue this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code entry error: ' + error, 'warn') } finally { - try { - rl.close() + try { + rl.close() } catch { // Intentionally silent: readline interface already closed or error during cleanup // This is a cleanup operation that shouldn't throw @@ -777,7 +777,7 @@ export class Login { } private async ensureTotpInput(page: Page): Promise { - const selector = await this.findFirstTotpInput(page) + const selector = await this.findFirstTotpInput(page) if (selector) return selector const attempts = 4 @@ -809,7 +809,7 @@ export class Login { try { const code = generateTOTP(this.currentTotpSecret!.trim()) const input = page.locator(selector).first() - if (!await input.isVisible().catch(()=>false)) { + if (!await input.isVisible().catch(() => false)) { this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn') return } @@ -899,14 +899,14 @@ export class Login { const attr = async (name: string) => (await locator.getAttribute(name) || '').toLowerCase() const type = await attr('type') - + // Explicit exclusions: never treat email or password fields as TOTP if (type === 'email' || type === 'password') return false const nameAttr = await attr('name') // Explicit exclusions: login/email/password field names if (nameAttr.includes('loginfmt') || nameAttr.includes('passwd') || nameAttr.includes('email') || nameAttr.includes('login')) return false - + // Strong positive signals for TOTP if (nameAttr.includes('otc') || nameAttr.includes('otp') || nameAttr.includes('code')) return true @@ -953,21 +953,21 @@ export class Login { if (el && el.textContent) texts.push(el.textContent) }) return texts.join(' ') - }).catch(()=>'') + }).catch(() => '') if (labelText && /code|otp|authenticator|sécurité|securité|security/i.test(labelText)) return true if (headingHint && /code|otp|authenticator/i.test(headingHint.toLowerCase())) return true - } catch {/* fall through to false */} + } catch {/* fall through to false */ } return false } private async detectTotpHeading(page: Page): Promise { const headings = page.locator('[data-testid="title"], h1, h2, div[role="heading"]') - const count = await headings.count().catch(()=>0) + const count = await headings.count().catch(() => 0) const max = Math.min(count, 6) for (let i = 0; i < max; i++) { - const text = (await headings.nth(i).textContent().catch(()=>null))?.trim() + const text = (await headings.nth(i).textContent().catch(() => null))?.trim() if (!text) continue const lowered = text.toLowerCase() if (/authenticator/.test(lowered) && /code/.test(lowered)) return text @@ -1019,14 +1019,14 @@ export class Login { const start = Date.now() let lastLogTime = start let checkCount = 0 - + while (Date.now() - start < timeoutMs) { checkCount++ - + // OPTIMIZATION: Fast URL check first (no DOM query needed) const url = page.url() const isRewardsDomain = url.includes('rewards.bing.com') || url.includes('rewards.microsoft.com') - + if (isRewardsDomain) { // OPTIMIZATION: Parallel checks for authenticated state const [hasContent, notLoggedIn, hasAuthIndicators] = await Promise.all([ @@ -1042,7 +1042,7 @@ export class Login { return true } } - } catch {/* ignore */} + } catch {/* ignore */ } } return false }).catch(() => false), @@ -1052,32 +1052,32 @@ export class Login { try { const el = document.querySelector(sel) if (el && (el as HTMLElement).offsetParent !== null) return true - } catch {/* ignore */} + } catch {/* ignore */ } } return false }).catch(() => false) ]) - + if (hasContent && !notLoggedIn && hasAuthIndicators) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Rewards page detected (authenticated)') return 'rewards-url-authenticated' } - + if (hasContent && notLoggedIn) { this.bot.log(this.bot.isMobile, 'LOGIN', 'On rewards page but not authenticated yet', 'warn') } } - + // OPTIMIZATION: Check selectors in batches for speed if (checkCount % 2 === 0) { // Every other iteration for (const sel of selectors) { const loc = page.locator(sel).first() - if (await loc.isVisible().catch(()=>false)) { + if (await loc.isVisible().catch(() => false)) { return sel } } } - + // Progress logging const now = Date.now() if (now - lastLogTime > 5000) { @@ -1085,7 +1085,7 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', `Still waiting for portal... (${elapsed}s, URL: ${url})`, 'warn') lastLogTime = now } - + // OPTIMIZATION: Adaptive polling const pollDelay = Date.now() - start < 5000 ? DEFAULT_TIMEOUTS.elementCheck : DEFAULT_TIMEOUTS.short await this.bot.utils.wait(pollDelay) @@ -1098,23 +1098,23 @@ export class Login { const start = Date.now() let lastUrl = '' let checkCount = 0 - + // EARLY EXIT: Check if already logged in immediately const initialState = await LoginStateDetector.detectState(page) if (initialState.state === LoginState.LoggedIn) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Already on rewards portal (early exit)') return } - + while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) { checkCount++ - + const currentUrl = page.url() if (currentUrl !== lastUrl) { this.bot.log(this.bot.isMobile, 'LOGIN', `Navigation: ${currentUrl}`) lastUrl = currentUrl } - + // SMART CHECK: Use LoginStateDetector every 5 iterations for fast detection if (checkCount % 5 === 0) { const state = await LoginStateDetector.detectState(page) @@ -1127,7 +1127,7 @@ export class Login { throw new Error('Account blocked during login') } } - + // OPTIMIZATION: Quick URL check first const u = new URL(currentUrl) const isRewardsHost = u.hostname === LOGIN_TARGET.host @@ -1136,7 +1136,7 @@ export class Login { || u.pathname === '/rewardsapp/dashboard' || u.pathname.startsWith('/?') if (isRewardsHost && isKnownPath) break - + // OPTIMIZATION: Handle prompts only every 3rd check if (checkCount % 3 === 0) { await Promise.allSettled([ @@ -1146,7 +1146,7 @@ export class Login { } else { await this.handlePasskeyPrompts(page, 'main') } - + // OPTIMIZATION: Adaptive wait const waitTime = Date.now() - start < 10000 ? DEFAULT_TIMEOUTS.fastPoll : 1000 await this.bot.utils.wait(waitTime) @@ -1154,10 +1154,10 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Checking for portal elements...') const portalSelector = await this.waitForRewardsRoot(page, DEFAULT_TIMEOUTS.portalWaitMs) - + if (!portalSelector) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal not found, trying goHome() fallback...', 'warn') - + try { await this.bot.browser.func.goHome(page) await this.bot.utils.wait(1500) // Reduced from 2000ms @@ -1167,11 +1167,11 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Retry: checking for portal elements...') const fallbackSelector = await this.waitForRewardsRoot(page, DEFAULT_TIMEOUTS.portalWaitMs) - + if (!fallbackSelector) { const currentUrl = page.url() this.bot.log(this.bot.isMobile, 'LOGIN', `Current URL: ${currentUrl}`, 'error') - + // OPTIMIZATION: Get page info in one evaluation const pageContent = await page.evaluate(() => { return { @@ -1181,9 +1181,9 @@ export class Login { visibleElements: document.querySelectorAll('*[data-role-name], *[data-bi-name], main, #dashboard').length } }).catch(() => ({ title: 'unknown', bodyLength: 0, hasRewardsText: false, visibleElements: 0 })) - - this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error') - this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing', 'error') + + this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing', 'error') throw new Error(`Rewards portal not detected. URL: ${currentUrl}. Check reports/ folder`) } this.bot.log(this.bot.isMobile, 'LOGIN', `Portal found via fallback (${fallbackSelector})`) @@ -1218,27 +1218,27 @@ export class Login { private async verifyBingContext(page: Page) { try { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing auth context') - + const verificationUrl = 'https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F' - + // Use centralized navigation retry logic const { success: navigationSucceeded } = await this.navigateWithRetry( page, verificationUrl, 'LOGIN-BING' ) - + if (!navigationSucceeded) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification navigation failed after multiple attempts', 'warn') return } - + await this.bot.utils.wait(DEFAULT_TIMEOUTS.medium) const content = await page.content().catch(() => '') - const hasHttp400 = content.includes('HTTP ERROR 400') || - content.includes('This page isn\'t working') || - content.includes('This page is not working') - + const hasHttp400 = content.includes('HTTP ERROR 400') || + content.includes('This page isn\'t working') || + content.includes('This page is not working') + if (hasHttp400) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'HTTP 400 detected during Bing verification, reloading...', 'warn') const isLinux = process.platform === 'linux' @@ -1246,46 +1246,46 @@ export class Login { await page.reload({ waitUntil: 'domcontentloaded', timeout }).catch(logError('LOGIN-BING', 'Reload after HTTP 400 failed', this.bot.isMobile)) await this.bot.utils.wait(DEFAULT_TIMEOUTS.medium) } - + const maxIterations = this.bot.isMobile ? DEFAULT_TIMEOUTS.bingVerificationMaxIterationsMobile : DEFAULT_TIMEOUTS.bingVerificationMaxIterations for (let i = 0; i < maxIterations; i++) { const u = new URL(page.url()) - + if (u.hostname === 'www.bing.com' && u.pathname === '/') { await this.bot.browser.utils.tryDismissAllMessages(page) - + const ok = await page.waitForSelector('#id_n', { timeout: 3000 }).then(() => true).catch(() => false) if (ok) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification passed (user profile detected)') return } - + if (this.bot.isMobile) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification passed (mobile mode - profile check skipped)') return } } - + if (u.hostname.includes('login.live.com') || u.hostname.includes('login.microsoftonline.com')) { await this.handlePasskeyPrompts(page, 'main') await this.tryAutoTotp(page, 'bing-verification') } - + const waitTime = i < 3 ? 1000 : 1500 await this.bot.utils.wait(waitTime) } - + const finalUrl = page.url() if (finalUrl.includes('www.bing.com')) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification completed (on Bing domain, assuming success)') } else { this.bot.log(this.bot.isMobile, 'LOGIN-BING', `Bing verification uncertain - final URL: ${finalUrl}`, 'warn') } - + } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e) this.bot.log(this.bot.isMobile, 'LOGIN-BING', `Bing verification error: ${errorMsg}`, 'warn') - + if (errorMsg.includes('Proxy connection failed')) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Skipping Bing verification due to proxy issues - continuing anyway', 'warn') } else { @@ -1295,9 +1295,9 @@ export class Login { } private async checkAccountLocked(page: Page) { - const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(()=>true).catch(()=>false) + const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(() => true).catch(() => false) if (locked) { - this.bot.log(this.bot.isMobile,'CHECK-LOCKED','Account locked by Microsoft (serviceAbuseLandingTitle)','error') + this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'Account locked by Microsoft (serviceAbuseLandingTitle)', 'error') throw new Error('Account locked by Microsoft - please review account status') } } @@ -1305,9 +1305,9 @@ export class Login { // --------------- Passkey / Dialog Handling --------------- private async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') { let did = false - + // Priority 1: Direct detection of "Skip for now" button by data-testid - const skipBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(()=>null) + const skipBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null) if (skipBtn) { const text = (await skipBtn.textContent() || '').trim() // Check if it's actually a skip button (could be other secondary buttons) @@ -1317,52 +1317,52 @@ export class Login { this.logPasskeyOnce('data-testid secondaryButton') } } - + // Priority 2: Video heuristic (biometric prompt) if (!did) { - const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(()=>null) + const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(() => null) if (biometric) { const btn = await page.$(SELECTORS.passkeySecondary) - if (btn) { + if (btn) { await btn.click().catch(logError('LOGIN-PASSKEY', 'Video heuristic click failed', this.bot.isMobile)) did = true - this.logPasskeyOnce('video heuristic') + this.logPasskeyOnce('video heuristic') } } } - + // Priority 3: Title + secondary button detection if (!did) { - const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(()=>null) - const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(()=>null) - const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(()=>null) + const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(() => null) + const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(() => null) + const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(() => null) const title = (titleEl ? (await titleEl.textContent()) : '')?.trim() || '' const looksLike = /sign in faster|passkey|fingerprint|face|pin|empreinte|visage|windows hello|hello/i.test(title) - if (looksLike && secBtn) { + if (looksLike && secBtn) { await secBtn.click().catch(logError('LOGIN-PASSKEY', 'Title heuristic click failed', this.bot.isMobile)) did = true - this.logPasskeyOnce('title heuristic '+title) + this.logPasskeyOnce('title heuristic ' + title) } else if (!did && secBtn && primBtn) { - const text = (await secBtn.textContent()||'').trim() - if (/skip for now|not now|later|passer|plus tard/i.test(text)) { + const text = (await secBtn.textContent() || '').trim() + if (/skip for now|not now|later|passer|plus tard/i.test(text)) { await secBtn.click().catch(logError('LOGIN-PASSKEY', 'Secondary button text click failed', this.bot.isMobile)) did = true - this.logPasskeyOnce('secondary button text') + this.logPasskeyOnce('secondary button text') } } } - + // Priority 4: XPath fallback (includes Windows Hello specific patterns) if (!did) { const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now") or contains(normalize-space(.),"Not now") or contains(normalize-space(.),"Passer") or contains(normalize-space(.),"No thanks")]').first() - if (await textBtn.isVisible().catch(()=>false)) { + if (await textBtn.isVisible().catch(() => false)) { await textBtn.click().catch(logError('LOGIN-PASSKEY', 'XPath fallback click failed', this.bot.isMobile)) did = true - this.logPasskeyOnce('xpath fallback') + this.logPasskeyOnce('xpath fallback') } } - + // Priority 4.5: Windows Hello specific detection if (!did) { const windowsHelloTitle = await page.locator('text=/windows hello/i').first().isVisible().catch(() => false) @@ -1387,25 +1387,25 @@ export class Login { } } } - + // Priority 5: Close button fallback if (!did) { const close = await page.$('#close-button') - if (close) { + if (close) { await close.click().catch(logError('LOGIN-PASSKEY', 'Close button fallback failed', this.bot.isMobile)) did = true - this.logPasskeyOnce('close button') + this.logPasskeyOnce('close button') } } // KMSI prompt - const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(()=>null) + const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null) if (kmsi) { const yes = await page.$(SELECTORS.passkeyPrimary) - if (yes) { + if (yes) { await yes.click().catch(logError('LOGIN-KMSI', 'KMSI accept click failed', this.bot.isMobile)) did = true - this.bot.log(this.bot.isMobile,'LOGIN-KMSI','Accepted KMSI prompt') + this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt') } } @@ -1414,7 +1414,7 @@ export class Login { const now = Date.now() if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) { this.lastNoPromptLog = now - this.bot.log(this.bot.isMobile,'LOGIN-NO-PROMPT',`No dialogs (x${this.noPromptIterations})`) + this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`) if (this.noPromptIterations > 50) this.noPromptIterations = 0 } } else if (did) { @@ -1425,7 +1425,7 @@ export class Login { private logPasskeyOnce(reason: string) { if (this.passkeyHandled) return this.passkeyHandled = true - this.bot.log(this.bot.isMobile,'LOGIN-PASSKEY',`Dismissed passkey prompt (${reason})`) + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`) } // --------------- Security Detection --------------- @@ -1433,11 +1433,11 @@ export class Login { if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') return true try { let text = '' - for (const sel of ['[data-testid="title"]','h1','div[role="heading"]','div.text-title']) { - const el = await page.waitForSelector(sel, { timeout: 600 }).catch(()=>null) + for (const sel of ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']) { + const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null) if (el) { - const t = (await el.textContent()||'').trim() - if (t && t.length < 300) text += ' '+t + const t = (await el.textContent() || '').trim() + if (t && t.length < 300) text += ' ' + t } } const lower = text.toLowerCase() @@ -1453,7 +1453,7 @@ export class Login { next: ['Manual recovery required before continuing'], docsUrl } - await this.sendIncidentAlert(incident,'warn') + await this.sendIncidentAlert(incident, 'warn') this.bot.compromisedModeActive = true this.bot.compromisedReason = 'sign-in-blocked' this.startCompromisedInterval() @@ -1464,45 +1464,45 @@ export class Login { } catch { return false } } - private async tryRecoveryMismatchCheck(page: Page, email: string) { - try { - await this.detectAndHandleRecoveryMismatch(page, email) + private async tryRecoveryMismatchCheck(page: Page, email: string) { + try { + await this.detectAndHandleRecoveryMismatch(page, email) } catch { // Intentionally silent: Recovery mismatch check is a best-effort security check // Failure here should not break the login flow as the page may simply not have recovery info - } + } } private async detectAndHandleRecoveryMismatch(page: Page, email: string) { try { const recoveryEmail: string | undefined = this.bot.currentAccountRecoveryEmail if (!recoveryEmail || !/@/.test(recoveryEmail)) return const accountEmail = email - const parseRef = (val: string) => { const [l,d] = val.split('@'); return { local: l||'', domain:(d||'').toLowerCase(), prefix2:(l||'').slice(0,2).toLowerCase() } } - const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r=>r.domain && r.prefix2) + const parseRef = (val: string) => { const [l, d] = val.split('@'); return { local: l || '', domain: (d || '').toLowerCase(), prefix2: (l || '').slice(0, 2).toLowerCase() } } + const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r => r.domain && r.prefix2) if (refs.length === 0) return const candidates: string[] = [] // Direct selectors (Microsoft variants + French spans) const sel = '[data-testid="recoveryEmailHint"], #recoveryEmail, [id*="ProofEmail"], [id*="EmailProof"], [data-testid*="Email"], span:has(span.fui-Text)' - const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(()=>null) - if (el) { const t = (await el.textContent()||'').trim(); if (t) candidates.push(t) } + const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(() => null) + if (el) { const t = (await el.textContent() || '').trim(); if (t) candidates.push(t) } // List items const li = page.locator('[role="listitem"], li') - const liCount = await li.count().catch(()=>0) - for (let i=0;i''))?.trim()||''; if (t && /@/.test(t)) candidates.push(t) } + const liCount = await li.count().catch(() => 0) + for (let i = 0; i < liCount && i < 12; i++) { const t = (await li.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && /@/.test(t)) candidates.push(t) } // XPath generic masked patterns const xp = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]') - const xpCount = await xp.count().catch(()=>0) - for (let i=0;i''))?.trim()||''; if (t && t.length<300) candidates.push(t) } + const xpCount = await xp.count().catch(() => 0) + for (let i = 0; i < xpCount && i < 12; i++) { const t = (await xp.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && t.length < 300) candidates.push(t) } // Normalize const seen = new Set() - const norm = (s:string)=>s.replace(/\s+/g,' ').trim() - const uniq = candidates.map(norm).filter(t=>t && !seen.has(t) && seen.add(t)) + const norm = (s: string) => s.replace(/\s+/g, ' ').trim() + const uniq = candidates.map(norm).filter(t => t && !seen.has(t) && seen.add(t)) // Masked filter - let masked = uniq.filter(t=>/@/.test(t) && /[*•]/.test(t)) + let masked = uniq.filter(t => /@/.test(t) && /[*•]/.test(t)) if (masked.length === 0) { // Fallback full HTML scan @@ -1513,14 +1513,14 @@ export class Login { const found = new Set() let m: RegExpExecArray | null 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) - } catch {/* ignore */} + } catch {/* ignore */ } } if (masked.length === 0) return // Prefer one mentioning email/adresse - const preferred = masked.find(t=>/email|courriel|adresse|mail/i.test(t)) || masked[0]! + const preferred = masked.find(t => /email|courriel|adresse|mail/i.test(t)) || masked[0]! // Extract the masked email: Microsoft sometimes shows only first 1 char (k*****@domain) or 2 chars (ko*****@domain). // We ONLY compare (1 or 2) leading visible alphanumeric chars + full domain (case-insensitive). // This avoids false positives when the displayed mask hides the 2nd char. @@ -1531,15 +1531,15 @@ export class Login { const use = m || loose const extracted = use ? use[0] : preferred const extractedLower = extracted.toLowerCase() - let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase() - let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase() + let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase() + let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase() if (!observedDomain && extractedLower.includes('@')) { const parts = extractedLower.split('@') observedDomain = parts[1] || '' } if (!observedPrefix && extractedLower.includes('@')) { const parts = extractedLower.split('@') - observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi,'').slice(0,2) + observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi, '').slice(0, 2) } // Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic @@ -1555,22 +1555,22 @@ export class Login { if (!matchRef) { const docsUrl = this.getDocsUrl('recovery-email-mismatch') const incident: SecurityIncident = { - kind:'Recovery email mismatch', + kind: 'Recovery email mismatch', account: email, - details:[ + details: [ `MaskedShown: ${preferred}`, `Extracted: ${extracted}`, `Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`, - `Expected => ${refs.map(r=>`${r.prefix2}**@${r.domain}`).join(' OR ')}` + `Expected => ${refs.map(r => `${r.prefix2}**@${r.domain}`).join(' OR ')}` ], - next:[ + next: [ 'Automation halted globally (standby engaged).', 'Verify account security & recovery email in Microsoft settings.', 'Update accounts.json if the change was legitimate before restart.' ], docsUrl } - await this.sendIncidentAlert(incident,'critical') + await this.sendIncidentAlert(incident, 'critical') this.bot.compromisedModeActive = true this.bot.compromisedReason = 'recovery-mismatch' this.startCompromisedInterval() @@ -1578,32 +1578,32 @@ export class Login { await this.openDocsTab(page, docsUrl).catch(logError('LOGIN-RECOVERY', 'Failed to open docs tab', this.bot.isMobile)) } else { 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-fatal */ } } private async switchToPasswordLink(page: Page) { try { const link = await page.locator('xpath=//span[@role="button" and (contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"use your password") or contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"utilisez votre mot de passe"))]').first() - if (await link.isVisible().catch(()=>false)) { + if (await link.isVisible().catch(() => false)) { await link.click().catch(logError('LOGIN', 'Use password link click failed', this.bot.isMobile)) 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 {/* ignore */ } } // --------------- Incident Helpers --------------- - private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn'|'critical'='warn') { - const lines = [ `[Incident] ${incident.kind}`, `Account: ${incident.account}` ] + private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn' | 'critical' = 'warn') { + const lines = [`[Incident] ${incident.kind}`, `Account: ${incident.account}`] if (incident.details?.length) lines.push(`Details: ${incident.details.join(' | ')}`) if (incident.next?.length) lines.push(`Next: ${incident.next.join(' -> ')}`) if (incident.docsUrl) lines.push(`Docs: ${incident.docsUrl}`) - const level: 'warn'|'error' = severity === 'critical' ? 'error' : 'warn' - this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level) + const level: 'warn' | 'error' = severity === 'critical' ? 'error' : 'warn' + this.bot.log(this.bot.isMobile, 'SECURITY', lines.join(' | '), level) try { - const { ConclusionWebhook } = await import('../util/ConclusionWebhook') + const { ConclusionWebhook } = await import('../util/notifications/ConclusionWebhook') const fields = [ { name: 'Account', value: incident.account }, ...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []), @@ -1617,14 +1617,14 @@ export class Login { fields, severity === 'critical' ? 0xFF0000 : 0xFFAA00 ) - } catch {/* ignore */} + } catch {/* ignore */ } } private getDocsUrl(anchor?: string) { const base = process.env.DOCS_BASE?.trim() || 'https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/blob/main/docs/security.md' - const map: Record = { - 'recovery-email-mismatch':'#recovery-email-mismatch', - 'we-cant-sign-you-in':'#we-cant-sign-you-in-blocked' + const map: Record = { + 'recovery-email-mismatch': '#recovery-email-mismatch', + 'we-cant-sign-you-in': '#we-cant-sign-you-in-blocked' } return anchor && map[anchor] ? `${base}${map[anchor]}` : base } @@ -1635,9 +1635,9 @@ export class Login { clearInterval(this.compromisedInterval) this.compromisedInterval = undefined } - this.compromisedInterval = setInterval(()=>{ - try { - this.bot.log(this.bot.isMobile,'SECURITY','Security standby active. Manual review required before proceeding.','warn') + this.compromisedInterval = setInterval(() => { + try { + this.bot.log(this.bot.isMobile, 'SECURITY', 'Security standby active. Manual review required before proceeding.', 'warn') } catch { // Intentionally silent: If logging fails in interval, don't crash the timer // The interval will try again in 5 minutes @@ -1657,7 +1657,7 @@ export class Login { const ctx = page.context() const tab = await ctx.newPage() await tab.goto(url, { waitUntil: 'domcontentloaded' }) - } catch {/* ignore */} + } catch {/* ignore */ } } // --------------- Infrastructure --------------- diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index c364a51..9d6e9ab 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -4,23 +4,23 @@ import { TIMEOUTS } from '../constants' import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData' import { MicrosoftRewardsBot } from '../index' -import { AdaptiveThrottler } from '../util/AdaptiveThrottler' -import JobState from '../util/JobState' -import { logError } from '../util/Logger' -import { Retry } from '../util/Retry' +import { Retry } from '../util/core/Retry' +import { AdaptiveThrottler } from '../util/notifications/AdaptiveThrottler' +import { logError } from '../util/notifications/Logger' +import JobState from '../util/state/JobState' // Selector patterns (extracted to avoid magic strings) const ACTIVITY_SELECTORS = { - byName: (name: string) => `[data-bi-id^="${name}"] .pointLink:not(.contentContainer .pointLink)`, - byOfferId: (offerId: string) => `[data-bi-id^="${offerId}"] .pointLink:not(.contentContainer .pointLink)` + byName: (name: string) => `[data-bi-id^="${name}"] .pointLink:not(.contentContainer .pointLink)`, + 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 + THROTTLE_MIN: 800, + THROTTLE_MAX: 1400, + ACTIVITY_SPACING_MIN: 1200, + ACTIVITY_SPACING_MAX: 2600 } as const export class Workers { @@ -220,9 +220,9 @@ export class Workers { if (!activity.offerId) { // 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.`, + 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) @@ -239,7 +239,7 @@ export class Workers { private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise { this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`) - + // Check if element exists before clicking (avoid 30s timeout) try { await page.waitForSelector(selector, { timeout: TIMEOUTS.NETWORK_IDLE }) @@ -254,7 +254,7 @@ export class Workers { // Execute activity with timeout protection using Promise.race const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2 - + await retry.run(async () => { const activityPromise = this.bot.activities.run(page, activity) const timeoutPromise = new Promise((_, reject) => { @@ -264,7 +264,7 @@ export class Workers { // Clean up timer if activity completes first activityPromise.finally(() => clearTimeout(timer)) }) - + try { await Promise.race([activityPromise, timeoutPromise]) throttle.record(true) diff --git a/src/index.ts b/src/index.ts index 30fb827..2503b3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,16 +7,16 @@ import type { Page } from 'playwright' import { createInterface } from 'readline' import BrowserFunc from './browser/BrowserFunc' import BrowserUtil from './browser/BrowserUtil' -import Axios from './util/Axios' -import { detectBanReason } from './util/BanDetector' -import Humanizer from './util/Humanizer' -import JobState from './util/JobState' -import { loadAccounts, loadConfig } from './util/Load' -import { log } from './util/Logger' -import { MobileRetryTracker } from './util/MobileRetryTracker' -import { QueryDiversityEngine } from './util/QueryDiversityEngine' -import { StartupValidator } from './util/StartupValidator' -import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/Utils' +import Humanizer from './util/browser/Humanizer' +import { formatDetailedError, normalizeRecoveryEmail, shortErrorMessage, Util } from './util/core/Utils' +import Axios from './util/network/Axios' +import { QueryDiversityEngine } from './util/network/QueryDiversityEngine' +import { log } from './util/notifications/Logger' +import JobState from './util/state/JobState' +import { loadAccounts, loadConfig } from './util/state/Load' +import { MobileRetryTracker } from './util/state/MobileRetryTracker' +import { detectBanReason } from './util/validation/BanDetector' +import { StartupValidator } from './util/validation/StartupValidator' import { Activities } from './functions/Activities' import { Login } from './functions/Login' @@ -629,7 +629,7 @@ export class MicrosoftRewardsBot { try { const h = this.config?.humanization if (!h || h.immediateBanAlert === false) return - const { ConclusionWebhook } = await import('./util/ConclusionWebhook') + const { ConclusionWebhook } = await import('./util/notifications/ConclusionWebhook') await ConclusionWebhook( this.config, '🚫 Ban Detected', @@ -806,7 +806,7 @@ export class MicrosoftRewardsBot { /** Send a strong alert to all channels and mention @everyone when entering global security standby. */ private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise { try { - const { ConclusionWebhook } = await import('./util/ConclusionWebhook') + const { ConclusionWebhook } = await import('./util/notifications/ConclusionWebhook') await ConclusionWebhook( this.config, '🚨 Critical Security Alert', diff --git a/src/util/BrowserFactory.ts b/src/util/browser/BrowserFactory.ts similarity index 91% rename from src/util/BrowserFactory.ts rename to src/util/browser/BrowserFactory.ts index d4a5a0d..ab146c7 100644 --- a/src/util/BrowserFactory.ts +++ b/src/util/browser/BrowserFactory.ts @@ -6,8 +6,8 @@ */ import type { BrowserContext } from 'rebrowser-playwright' -import type { MicrosoftRewardsBot } from '../index' -import type { AccountProxy } from '../interface/Account' +import type { MicrosoftRewardsBot } from '../../index' +import type { AccountProxy } from '../../interface/Account' /** * Create a browser instance for the given account @@ -26,7 +26,7 @@ export async function createBrowserInstance( proxy: AccountProxy, email: string ): Promise { - const browserModule = await import('../browser/Browser') + const browserModule = await import('../../browser/Browser') const Browser = browserModule.default const browserInstance = new Browser(bot) return await browserInstance.createBrowser(proxy, email) diff --git a/src/util/Humanizer.ts b/src/util/browser/Humanizer.ts similarity index 92% rename from src/util/Humanizer.ts rename to src/util/browser/Humanizer.ts index 98e85c0..e54ec1b 100644 --- a/src/util/Humanizer.ts +++ b/src/util/browser/Humanizer.ts @@ -1,6 +1,6 @@ import { Page } from 'rebrowser-playwright' -import { Util } from './Utils' -import type { ConfigHumanization } from '../interface/Config' +import type { ConfigHumanization } from '../../interface/Config' +import { Util } from '../core/Utils' export class Humanizer { private util: Util @@ -46,9 +46,9 @@ export class Humanizer { try { const n = this.util.stringToMs(String(v)) return Math.max(0, Math.min(n, 10_000)) - } catch (e) { + } catch (e) { // Parse failed - use default minimum - return defMin + return defMin } } min = parse(this.cfg.actionDelay.min) diff --git a/src/util/UserAgent.ts b/src/util/browser/UserAgent.ts similarity index 95% rename from src/util/UserAgent.ts rename to src/util/browser/UserAgent.ts index 65554ac..86f5853 100644 --- a/src/util/UserAgent.ts +++ b/src/util/browser/UserAgent.ts @@ -1,8 +1,8 @@ import axios from 'axios' import { BrowserFingerprintWithHeaders } from 'fingerprint-generator' -import { Architecture, ChromeVersion, EdgeVersion, Platform } from '../interface/UserAgentUtil' -import { log } from './Logger' -import { Retry } from './Retry' +import { Architecture, ChromeVersion, EdgeVersion, Platform } from '../../interface/UserAgentUtil' +import { Retry } from '../core/Retry' +import { log } from '../notifications/Logger' interface UserAgentMetadata { mobile: boolean @@ -95,7 +95,7 @@ export async function getChromeVersion(isMobile: boolean): Promise { export async function getEdgeVersions(isMobile: boolean): Promise { const now = Date.now() - + // Return cached version if still valid if (edgeVersionCache && edgeVersionCache.expiresAt > now) { return edgeVersionCache.data @@ -123,13 +123,13 @@ export async function getEdgeVersions(isMobile: boolean): Promise { edgeVersionInFlight = null - + // Try stale cache first if (edgeVersionCache) { log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using stale cached Edge versions due to fetch failure', 'warn') return edgeVersionCache.data } - + // Fall back to static versions log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using static fallback Edge versions (API unavailable)', 'warn') edgeVersionCache = { data: FALLBACK_EDGE_VERSIONS, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS } @@ -192,7 +192,7 @@ async function fetchEdgeVersionsWithRetry(isMobile: boolean): Promise { let lastError: unknown = null - + // Try axios first try { const response = await axios({ @@ -205,11 +205,11 @@ async function fetchEdgeVersionsOnce(isMobile: boolean): Promise status === 200 }) - + if (!response.data || !Array.isArray(response.data)) { throw new Error('Invalid response format from Edge API') } - + return mapEdgeVersions(response.data) } catch (axiosError) { lastError = axiosError @@ -226,7 +226,7 @@ async function fetchEdgeVersionsOnce(isMobile: boolean): Promise { try { const controller = new AbortController() timeoutHandle = setTimeout(() => controller.abort(), 10000) - + const response = await fetch(EDGE_VERSION_URL, { headers: { 'Content-Type': 'application/json', @@ -245,20 +245,20 @@ async function tryNativeFetchFallback(): Promise { }, signal: controller.signal }) - + clearTimeout(timeoutHandle) timeoutHandle = undefined - + if (!response.ok) { throw new Error(`HTTP ${response.status}`) } - + const data = await response.json() as EdgeVersion[] - + if (!Array.isArray(data)) { throw new Error('Invalid response format') } - + return mapEdgeVersions(data) } catch (error) { if (timeoutHandle) clearTimeout(timeoutHandle) @@ -270,24 +270,24 @@ function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult { if (!Array.isArray(data) || data.length === 0) { throw new Error('Edge API returned empty or invalid data') } - + const stable = data.find(entry => entry?.Product?.toLowerCase() === 'stable') ?? data.find(entry => entry?.Product && /stable/i.test(entry.Product)) - + if (!stable || !stable.Releases || !Array.isArray(stable.Releases)) { throw new Error('Stable Edge channel not found or invalid format') } - const androidRelease = stable.Releases.find(release => + const androidRelease = stable.Releases.find(release => release?.Platform === Platform.Android && release?.ProductVersion ) - - const windowsRelease = stable.Releases.find(release => - release?.Platform === Platform.Windows && - release?.Architecture === Architecture.X64 && + + const windowsRelease = stable.Releases.find(release => + release?.Platform === Platform.Windows && + release?.Architecture === Architecture.X64 && release?.ProductVersion - ) ?? stable.Releases.find(release => - release?.Platform === Platform.Windows && + ) ?? stable.Releases.find(release => + release?.Platform === Platform.Windows && release?.ProductVersion ) @@ -295,7 +295,7 @@ function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult { android: androidRelease?.ProductVersion, windows: windowsRelease?.ProductVersion } - + // Validate at least one version was found if (!result.android && !result.windows) { throw new Error('No valid Edge versions found in API response') diff --git a/src/util/Retry.ts b/src/util/core/Retry.ts similarity index 97% rename from src/util/Retry.ts rename to src/util/core/Retry.ts index 7dcbb57..c626b31 100644 --- a/src/util/Retry.ts +++ b/src/util/core/Retry.ts @@ -1,4 +1,4 @@ -import type { ConfigRetryPolicy } from '../interface/Config' +import type { ConfigRetryPolicy } from '../../interface/Config' import { Util } from './Utils' type NumericPolicy = { @@ -59,7 +59,7 @@ export class Retry { let attempt = 0 let delay = this.policy.baseDelay let lastErr: unknown - + while (attempt < this.policy.maxAttempts) { try { return await fn() diff --git a/src/util/Utils.ts b/src/util/core/Utils.ts similarity index 100% rename from src/util/Utils.ts rename to src/util/core/Utils.ts diff --git a/src/util/Axios.ts b/src/util/network/Axios.ts similarity index 92% rename from src/util/Axios.ts rename to src/util/network/Axios.ts index 7a712a6..97a636c 100644 --- a/src/util/Axios.ts +++ b/src/util/network/Axios.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } f import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import { SocksProxyAgent } from 'socks-proxy-agent' -import { AccountProxy } from '../interface/Account' +import { AccountProxy } from '../../interface/Account' class AxiosClient { private instance: AxiosInstance @@ -90,13 +90,13 @@ class AxiosClient { // FIXED: Initialize lastError to prevent throwing undefined let lastError: unknown = new Error('Request failed with unknown error') const maxAttempts = 2 - + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await this.instance.request(config) } catch (err: unknown) { lastError = err - + // Handle HTTP 407 Proxy Authentication Required if (this.isProxyAuthError(err)) { // Retry without proxy on auth failure @@ -116,15 +116,15 @@ class AxiosClient { const bypassInstance = axios.create() return bypassInstance.request(config) } - + // Non-retryable error throw err } } - + throw lastError } - + /** * Check if error is HTTP 407 Proxy Authentication Required */ @@ -132,27 +132,27 @@ class AxiosClient { 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 isNetworkError = code === 'ECONNREFUSED' || + code === 'ETIMEDOUT' || + code === 'ECONNRESET' || + code === 'ENOTFOUND' || + code === 'EPIPE' + const msg = String(e.message || '') const isProxyIssue = /proxy|tunnel|socks|agent/i.test(msg) - + return isNetworkError || isProxyIssue } - + private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/src/util/QueryDiversityEngine.ts b/src/util/network/QueryDiversityEngine.ts similarity index 97% rename from src/util/QueryDiversityEngine.ts rename to src/util/network/QueryDiversityEngine.ts index bbff6f7..b0bdb96 100644 --- a/src/util/QueryDiversityEngine.ts +++ b/src/util/network/QueryDiversityEngine.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { Util } from './Utils' +import { Util } from '../core/Utils' export interface QueryDiversityConfig { sources: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'> @@ -22,10 +22,10 @@ export class QueryDiversityEngine { constructor(config?: Partial, logger?: (source: string, message: string, level?: 'info' | 'warn' | 'error') => void) { const maxQueriesPerSource = Math.max(1, Math.min(config?.maxQueriesPerSource || 10, 50)) const cacheMinutes = Math.max(1, Math.min(config?.cacheMinutes || 30, 1440)) - + this.config = { - sources: config?.sources && config.sources.length > 0 - ? config.sources + sources: config?.sources && config.sources.length > 0 + ? config.sources : ['google-trends', 'reddit', 'local-fallback'], deduplicate: config?.deduplicate !== false, mixStrategies: config?.mixStrategies !== false, @@ -44,7 +44,7 @@ export class QueryDiversityEngine { /** * Generic HTTP fetch with error handling and timeout */ - private async fetchHttp(url: string, config?: { + private async fetchHttp(url: string, config?: { method?: 'GET' | 'POST' headers?: Record data?: string @@ -104,7 +104,7 @@ export class QueryDiversityEngine { */ private async getFromSource(source: string): Promise { this.cleanExpiredCache() - + const cached = this.cache.get(source) if (cached && Date.now() < cached.expires) { return cached.queries @@ -174,7 +174,7 @@ export class QueryDiversityEngine { try { const subreddits = ['news', 'worldnews', 'todayilearned', 'askreddit', 'technology'] const randomSub = subreddits[Math.floor(Math.random() * subreddits.length)] - + const data = await this.fetchHttp(`https://www.reddit.com/r/${randomSub}/hot.json?limit=15`) const parsed = JSON.parse(data) const posts = parsed.data?.children || [] @@ -296,28 +296,28 @@ export class QueryDiversityEngine { const result: string[] = [] const queriesPerSource = Math.ceil(this.config.maxQueriesPerSource) const sourceCount = this.config.sources.length - + if (sourceCount === 0 || queries.length === 0) { return queries.slice(0, targetCount) } - + const chunkSize = queriesPerSource let sourceIndex = 0 - + for (let i = 0; i < queries.length && result.length < targetCount; i++) { const currentChunkStart = sourceIndex * chunkSize const currentChunkEnd = currentChunkStart + chunkSize const query = queries[i] - + if (query && i >= currentChunkStart && i < currentChunkEnd) { result.push(query) } - + if (i === currentChunkEnd - 1) { sourceIndex = (sourceIndex + 1) % sourceCount } } - + return result.slice(0, targetCount) } diff --git a/src/util/AdaptiveThrottler.ts b/src/util/notifications/AdaptiveThrottler.ts similarity index 100% rename from src/util/AdaptiveThrottler.ts rename to src/util/notifications/AdaptiveThrottler.ts diff --git a/src/util/ConclusionWebhook.ts b/src/util/notifications/ConclusionWebhook.ts similarity index 97% rename from src/util/ConclusionWebhook.ts rename to src/util/notifications/ConclusionWebhook.ts index 678fbdb..175513c 100644 --- a/src/util/ConclusionWebhook.ts +++ b/src/util/notifications/ConclusionWebhook.ts @@ -1,8 +1,8 @@ import axios from 'axios' -import { Config } from '../interface/Config' -import { Ntfy } from './Ntfy' +import { DISCORD } from '../../constants' +import { Config } from '../../interface/Config' import { log } from './Logger' -import { DISCORD } from '../constants' +import { Ntfy } from './Ntfy' interface DiscordField { name: string diff --git a/src/util/ErrorReportingWebhook.ts b/src/util/notifications/ErrorReportingWebhook.ts similarity index 98% rename from src/util/ErrorReportingWebhook.ts rename to src/util/notifications/ErrorReportingWebhook.ts index c0b2703..8808394 100644 --- a/src/util/ErrorReportingWebhook.ts +++ b/src/util/notifications/ErrorReportingWebhook.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import { DISCORD } from '../constants' -import { Config } from '../interface/Config' +import { DISCORD } from '../../constants' +import { Config } from '../../interface/Config' interface ErrorReportPayload { error: string @@ -35,7 +35,7 @@ export function deobfuscateWebhookUrl(encoded: string): string { */ function shouldReportError(errorMessage: string): boolean { const lowerMessage = errorMessage.toLowerCase() - + // List of patterns that indicate user configuration errors (not reportable bugs) const userConfigPatterns = [ /accounts\.jsonc.*not found/i, @@ -59,14 +59,14 @@ function shouldReportError(errorMessage: string): boolean { /session closed.*rebrowser/i, /addScriptToEvaluateOnNewDocument.*session closed/i ] - + // Don't report user configuration errors for (const pattern of userConfigPatterns) { if (pattern.test(lowerMessage)) { return false } } - + // List of patterns that indicate expected/handled errors (not bugs) const expectedErrorPatterns = [ /no.*points.*to.*earn/i, @@ -76,14 +76,14 @@ function shouldReportError(errorMessage: string): boolean { /quest.*not.*found/i, /promotion.*expired/i ] - + // Don't report expected/handled errors for (const pattern of expectedErrorPatterns) { if (pattern.test(lowerMessage)) { return false } } - + // Report everything else (genuine bugs) return true } @@ -111,7 +111,7 @@ export async function sendErrorReport( } const errorMessage = error instanceof Error ? error.message : String(error) - + // Filter out false positives and user configuration errors if (!shouldReportError(errorMessage)) { return diff --git a/src/util/Logger.ts b/src/util/notifications/Logger.ts similarity index 99% rename from src/util/Logger.ts rename to src/util/notifications/Logger.ts index 50425f1..81c7b56 100644 --- a/src/util/Logger.ts +++ b/src/util/notifications/Logger.ts @@ -1,8 +1,8 @@ import axios from 'axios' import chalk from 'chalk' -import { DISCORD, LOGGER_CLEANUP } from '../constants' +import { DISCORD, LOGGER_CLEANUP } from '../../constants' +import { loadConfig } from '../state/Load' import { sendErrorReport } from './ErrorReportingWebhook' -import { loadConfig } from './Load' import { Ntfy } from './Ntfy' /** diff --git a/src/util/Ntfy.ts b/src/util/notifications/Ntfy.ts similarity index 96% rename from src/util/Ntfy.ts rename to src/util/notifications/Ntfy.ts index b8e2ae4..f94a735 100644 --- a/src/util/Ntfy.ts +++ b/src/util/notifications/Ntfy.ts @@ -1,5 +1,5 @@ -import { loadConfig } from './Load' import axios from 'axios' +import { loadConfig } from '../state/Load' const NOTIFICATION_TYPES = { error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/ diff --git a/src/util/Totp.ts b/src/util/security/Totp.ts similarity index 100% rename from src/util/Totp.ts rename to src/util/security/Totp.ts diff --git a/src/util/JobState.ts b/src/util/state/JobState.ts similarity index 98% rename from src/util/JobState.ts rename to src/util/state/JobState.ts index 5f93948..b33bb82 100644 --- a/src/util/JobState.ts +++ b/src/util/state/JobState.ts @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import type { Config } from '../interface/Config' +import type { Config } from '../../interface/Config' type AccountCompletionMeta = { runId?: string diff --git a/src/util/Load.ts b/src/util/state/Load.ts similarity index 97% rename from src/util/Load.ts rename to src/util/state/Load.ts index 1a932d3..4cbba44 100644 --- a/src/util/Load.ts +++ b/src/util/state/Load.ts @@ -2,9 +2,9 @@ import { BrowserFingerprintWithHeaders } from 'fingerprint-generator' import fs from 'fs' import path from 'path' import { BrowserContext, Cookie } from 'rebrowser-playwright' -import { Account } from '../interface/Account' -import { Config, ConfigBrowser, ConfigSaveFingerprint, ConfigScheduling } from '../interface/Config' -import { Util } from './Utils' +import { Account } from '../../interface/Account' +import { Config, ConfigBrowser, ConfigSaveFingerprint, ConfigScheduling } from '../../interface/Config' +import { Util } from '../core/Utils' const utils = new Util() @@ -76,16 +76,16 @@ function normalizeConfig(raw: unknown): Config { if (!raw || typeof raw !== 'object') { throw new Error('Config must be a valid object') } - + // eslint-disable-next-line @typescript-eslint/no-explicit-any const n = raw as Record // Browser settings const browserConfig = n.browser ?? {} - const headless = process.env.FORCE_HEADLESS === '1' - ? true - : (typeof browserConfig.headless === 'boolean' - ? browserConfig.headless + const headless = process.env.FORCE_HEADLESS === '1' + ? true + : (typeof browserConfig.headless === 'boolean' + ? browserConfig.headless : (typeof n.headless === 'boolean' ? n.headless : false)) // Legacy fallback const globalTimeout = browserConfig.globalTimeout ?? n.globalTimeout ?? '30s' @@ -339,12 +339,12 @@ export function loadAccounts(): Account[] { ] let chosen: string | null = null for (const p of candidates) { - try { - if (fs.existsSync(p)) { + try { + if (fs.existsSync(p)) { chosen = p - break - } - } catch (e) { + break + } + } catch (e) { // Filesystem check failed for this path, try next continue } @@ -365,12 +365,12 @@ export function loadAccounts(): Account[] { if (!entry || typeof entry !== 'object') { throw new Error('each account entry must be an object') } - + // Use Record to access dynamic properties from untrusted JSON // Runtime validation below ensures type safety // eslint-disable-next-line @typescript-eslint/no-explicit-any const a = entry as Record - + // Validate required fields with proper type checking if (typeof a.email !== 'string' || typeof a.password !== 'string') { throw new Error('each account must have email and password strings') @@ -439,15 +439,15 @@ export function loadConfig(): Config { candidates.push(path.join(base, name)) } } - + let cfgPath: string | null = null for (const p of candidates) { - try { - if (fs.existsSync(p)) { + try { + if (fs.existsSync(p)) { cfgPath = p - break - } - } catch (e) { + break + } + } catch (e) { // Filesystem check failed for this path, try next continue } @@ -517,7 +517,7 @@ export async function saveSessionData(sessionPath: string, browser: BrowserConte // Save cookies to a file await fs.promises.writeFile( - path.join(sessionDir, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`), + path.join(sessionDir, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`), JSON.stringify(cookies, null, 2) ) diff --git a/src/util/MobileRetryTracker.ts b/src/util/state/MobileRetryTracker.ts similarity index 100% rename from src/util/MobileRetryTracker.ts rename to src/util/state/MobileRetryTracker.ts diff --git a/src/util/BanDetector.ts b/src/util/validation/BanDetector.ts similarity index 100% rename from src/util/BanDetector.ts rename to src/util/validation/BanDetector.ts diff --git a/src/util/LoginStateDetector.ts b/src/util/validation/LoginStateDetector.ts similarity index 100% rename from src/util/LoginStateDetector.ts rename to src/util/validation/LoginStateDetector.ts diff --git a/src/util/StartupValidator.ts b/src/util/validation/StartupValidator.ts similarity index 96% rename from src/util/StartupValidator.ts rename to src/util/validation/StartupValidator.ts index 6fdd665..12c1b51 100644 --- a/src/util/StartupValidator.ts +++ b/src/util/validation/StartupValidator.ts @@ -1,9 +1,9 @@ import chalk from 'chalk' import fs from 'fs' import path from 'path' -import { Account } from '../interface/Account' -import { Config } from '../interface/Config' -import { log } from './Logger' +import { Account } from '../../interface/Account' +import { Config } from '../../interface/Config' +import { log } from '../notifications/Logger' interface ValidationError { severity: 'error' | 'warning' @@ -181,12 +181,12 @@ export class StartupValidator { private validateConfig(config: Config): void { const maybeSchedule = (config as unknown as { schedule?: unknown }).schedule if (maybeSchedule !== undefined) { - this.addWarning( - 'config', - 'Legacy schedule settings detected in config.jsonc.', - 'Remove schedule.* entries and use your operating system scheduler.', - 'docs/schedule.md' - ) + this.addWarning( + 'config', + 'Legacy schedule settings detected in config.jsonc.', + 'Remove schedule.* entries and use your operating system scheduler.', + 'docs/schedule.md' + ) } // Headless mode in Docker @@ -218,10 +218,10 @@ export class StartupValidator { } // Global timeout validation - const timeout = typeof config.globalTimeout === 'string' - ? config.globalTimeout + const timeout = typeof config.globalTimeout === 'string' + ? config.globalTimeout : `${config.globalTimeout}ms` - + if (timeout === '0' || timeout === '0ms' || timeout === '0s') { this.addError( 'config', @@ -271,7 +271,7 @@ export class StartupValidator { // Node.js version check const nodeVersion = process.version const major = parseInt(nodeVersion.split('.')[0]?.replace('v', '') || '0', 10) - + if (major < 18) { this.addError( 'environment', @@ -329,10 +329,10 @@ export class StartupValidator { // Check job-state directory if enabled if (config.jobState?.enabled !== false) { - const jobStateDir = config.jobState?.dir - ? config.jobState.dir + const jobStateDir = config.jobState?.dir + ? config.jobState.dir : path.join(sessionPath, 'job-state') - + if (!fs.existsSync(jobStateDir)) { try { fs.mkdirSync(jobStateDir, { recursive: true }) @@ -428,12 +428,12 @@ export class StartupValidator { private validateWorkerSettings(config: Config): void { const workers = config.workers - + // Check if at least one worker is enabled const anyEnabled = workers.doDailySet || workers.doMorePromotions || workers.doPunchCards || - workers.doDesktopSearch || workers.doMobileSearch || workers.doDailyCheckIn || - workers.doReadToEarn - + workers.doDesktopSearch || workers.doMobileSearch || workers.doDailyCheckIn || + workers.doReadToEarn + if (!anyEnabled) { this.addWarning( 'workers', @@ -465,7 +465,7 @@ export class StartupValidator { private validateExecutionSettings(config: Config): void { // Validate passesPerRun const passes = config.passesPerRun ?? 1 - + if (passes < 1) { this.addError( 'execution', @@ -595,8 +595,8 @@ export class StartupValidator { // Action delays if (human.actionDelay) { - const minMs = typeof human.actionDelay.min === 'string' - ? parseInt(human.actionDelay.min, 10) + const minMs = typeof human.actionDelay.min === 'string' + ? parseInt(human.actionDelay.min, 10) : human.actionDelay.min const maxMs = typeof human.actionDelay.max === 'string' ? parseInt(human.actionDelay.max, 10) @@ -717,7 +717,7 @@ export class StartupValidator { const errorLabel = this.errors.length === 1 ? 'error' : 'errors' const warningLabel = this.warnings.length === 1 ? 'warning' : 'warnings' log('main', 'VALIDATION', `[${this.errors.length > 0 ? 'ERROR' : 'OK'}] Found: ${this.errors.length} ${errorLabel} | ${this.warnings.length} ${warningLabel}`) - + if (this.errors.length > 0) { log('main', 'VALIDATION', 'Bot will continue, but issues may cause failures', 'warn') log('main', 'VALIDATION', 'Full documentation: docs/index.md')