From 697e81ee2af0f3fc75c176fb20696d10d741d987 Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Sat, 15 Nov 2025 17:07:19 +0100 Subject: [PATCH] feat: add free rewards redemption feature; require phone number in account config --- package-lock.json | 55 ++- package.json | 6 +- src/accounts.example.jsonc | 7 +- src/config.jsonc | 1 + src/flows/DesktopFlow.ts | 11 + src/functions/Workers.ts | 21 + src/functions/activities/FreeRewards.ts | 500 ++++++++++++++++++++++++ src/index.ts | 2 + src/interface/Account.ts | 2 + src/interface/Config.ts | 1 + 10 files changed, 583 insertions(+), 23 deletions(-) create mode 100644 src/functions/activities/FreeRewards.ts diff --git a/package-lock.json b/package-lock.json index 7c5c185..57e3a68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "chalk": "^4.1.2", "cheerio": "^1.0.0", "express": "^4.21.2", + "express-rate-limit": "^8.2.1", "fingerprint-generator": "^2.1.66", "fingerprint-injector": "^2.1.66", "http-proxy-agent": "^7.0.2", @@ -20,7 +21,7 @@ "luxon": "^3.5.0", "ms": "^2.1.3", "node-cron": "3.0.3", - "playwright": "1.56.1", + "playwright": "1.52.0", "rebrowser-playwright": "1.52.0", "socks-proxy-agent": "^8.0.5", "ts-node": "^10.9.2", @@ -1794,6 +1795,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3181,12 +3209,12 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.52.0" }, "bin": { "playwright": "cli.js" @@ -3199,21 +3227,10 @@ } }, "node_modules/playwright-core": { + "name": "rebrowser-playwright-core", "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "resolved": "https://registry.npmjs.org/rebrowser-playwright-core/-/rebrowser-playwright-core-1.52.0.tgz", + "integrity": "sha512-gjrvLNh0RX6B/tg6pWaPNGf+9+z1Jl2EyAh5MXD5xMa2lputGRZ9V2MJ/uofcC5Np3vSOJ3SdVSRqwteC0FjfQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index e5e585f..c53a07c 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "chalk": "^4.1.2", "cheerio": "^1.0.0", "express": "^4.21.2", + "express-rate-limit": "^8.2.1", "fingerprint-generator": "^2.1.66", "fingerprint-injector": "^2.1.66", "http-proxy-agent": "^7.0.2", @@ -83,11 +84,10 @@ "luxon": "^3.5.0", "ms": "^2.1.3", "node-cron": "3.0.3", - "playwright": "1.56.1", + "playwright": "1.52.0", "rebrowser-playwright": "1.52.0", "socks-proxy-agent": "^8.0.5", "ts-node": "^10.9.2", - "ws": "^8.18.3", - "express-rate-limit": "^8.2.1" + "ws": "^8.18.3" } } \ No newline at end of file diff --git a/src/accounts.example.jsonc b/src/accounts.example.jsonc index dcd256c..7e76d6b 100644 --- a/src/accounts.example.jsonc +++ b/src/accounts.example.jsonc @@ -8,6 +8,7 @@ "password": "", "totp": "", "recoveryEmail": "", + "phoneNumber": "", "proxy": { "proxyAxios": false, "url": "", @@ -22,6 +23,7 @@ "password": "", "totp": "", "recoveryEmail": "", + "phoneNumber": "", "proxy": { "proxyAxios": false, "url": "", @@ -36,6 +38,7 @@ "password": "", "totp": "", "recoveryEmail": "", + "phoneNumber": "", "proxy": { "proxyAxios": false, "url": "", @@ -50,6 +53,7 @@ "password": "", "totp": "", "recoveryEmail": "", + "phoneNumber": "", "proxy": { "proxyAxios": false, "url": "", @@ -64,6 +68,7 @@ "password": "", "totp": "", "recoveryEmail": "", + "phoneNumber": "", "proxy": { "proxyAxios": false, "url": "", @@ -73,4 +78,4 @@ } } ] -} +} \ No newline at end of file diff --git a/src/config.jsonc b/src/config.jsonc index 797794f..0ac875c 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -24,6 +24,7 @@ "doMobileSearch": true, "doDailyCheckIn": true, "doReadToEarn": true, + "doFreeRewards": false, "bundleDailySetWithSearch": true }, // === SEARCH === diff --git a/src/flows/DesktopFlow.ts b/src/flows/DesktopFlow.ts index 2ecb817..e5b7ee0 100644 --- a/src/flows/DesktopFlow.ts +++ b/src/flows/DesktopFlow.ts @@ -134,6 +134,17 @@ export class DesktopFlow { } } + // Do free rewards redemption + if (this.bot.config.workers.doFreeRewards) { + try { + await this.bot.workers.doFreeRewards(workerPage) + } catch (rewardsError) { + const errorMsg = rewardsError instanceof Error ? rewardsError.message : String(rewardsError) + this.bot.log(false, 'DESKTOP-FLOW', `Free rewards redemption failed: ${errorMsg}`, 'error') + // Don't throw - continue flow + } + } + // Fetch points BEFORE closing (avoid page closed reload error) const after = await this.bot.browser.func.getCurrentPoints().catch(() => initial) diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 6de56ff..493fb99 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -165,6 +165,27 @@ export class Workers { this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed') } + // Free Rewards + async doFreeRewards(page: Page) { + // Check if account has phone number configured + if (!this.bot.currentAccountPhoneNumber) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Skipped: No phone number configured for this account. Add "phoneNumber" field in accounts.jsonc to enable free rewards redemption.', 'warn') + return + } + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Starting free rewards redemption (0-point gift cards)') + + try { + const { FreeRewards } = await import('./activities/FreeRewards') + const freeRewards = new FreeRewards(this.bot) + await freeRewards.doFreeRewards(page) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Free rewards flow failed: ${errorMessage}`, 'error') + throw new Error(`Free rewards redemption failed: ${errorMessage}`) + } + } + // Solve all the different types of activities private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) { const activityInitial = activityPage.url() diff --git a/src/functions/activities/FreeRewards.ts b/src/functions/activities/FreeRewards.ts new file mode 100644 index 0000000..d6c4630 --- /dev/null +++ b/src/functions/activities/FreeRewards.ts @@ -0,0 +1,500 @@ +import { Page } from 'rebrowser-playwright' + +import { TIMEOUTS } from '../../constants' +import { waitForElementSmart, waitForNetworkIdle } from '../../util/browser/SmartWait' +import { Workers } from '../Workers' + +/** + * FreeRewards Activity Handler + * + * Automatically redeems 0-point gift cards and rewards from https://rewards.bing.com/redeem + * + * **IMPORTANT REQUIREMENTS:** + * - Account MUST have a phone number configured (`phoneNumber` field in accounts.jsonc) + * - Without a phone number, Microsoft blocks reward redemption (no value in executing this task) + * + * **ANTI-DETECTION MEASURES:** + * - Aggressive humanization (mouse gestures, scrolling, random delays) + * - Smart waiting for Cloudflare Turnstile CAPTCHA completion + * - Natural browsing patterns to avoid automation detection + * + * **WORKFLOW:** + * 1. Navigate to https://rewards.bing.com/redeem + * 2. Scan page for rewards with 0 points cost (class-based detection for multi-locale support) + * 3. Click the reward card to view details + * 4. Click "Redeem" button on product detail page + * 5. Wait for Cloudflare Turnstile CAPTCHA to complete (user must solve if detected) + * 6. Confirm redemption on checkout page + * 7. Verify success and return to redeem page for next reward + * + * **CLOUDFLARE TURNSTILE CAPTCHA HANDLING:** + * - Automatic detection of CAPTCHA presence (#turnstile-widget) + * - Extended timeout (up to 60s) for user to manually solve CAPTCHA + * - Aggressive humanization during wait (scrolling, mouse moves) + * - Configurable via `humanization.enabled` in config + * + * @class FreeRewards + * @extends Workers + */ +export class FreeRewards extends Workers { + + /** + * Main entry point for free rewards redemption + * + * @param page Playwright page instance (must be on rewards.bing.com) + * @returns Promise resolving when all free rewards are redeemed + * @throws {Error} If critical operation fails (navigation, page closed) + */ + override async doFreeRewards(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Starting free rewards redemption flow') + + try { + // STEP 1: Navigate to redeem page + await this.navigateToRedeemPage(page) + + // STEP 2: Find all 0-point rewards + const freeRewards = await this.findFreeRewards(page) + + if (freeRewards.length === 0) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'No free rewards (0 points) available today') + return + } + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Found ${freeRewards.length} free reward(s) available`) + + // STEP 3: Redeem each free reward + for (let i = 0; i < freeRewards.length; i++) { + const reward = freeRewards[i] + if (!reward) continue + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Processing reward ${i + 1}/${freeRewards.length}`) + + try { + await this.redeemSingleReward(page, reward) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Successfully redeemed reward ${i + 1}`, 'log', 'green') + } catch (rewardError) { + const errMsg = rewardError instanceof Error ? rewardError.message : String(rewardError) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Failed to redeem reward ${i + 1}: ${errMsg}`, 'error') + // Continue with next reward instead of failing entire flow + } + + // Navigate back to redeem page for next reward + if (i < freeRewards.length - 1) { + await this.navigateToRedeemPage(page) + } + } + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Completed free rewards redemption flow', 'log', 'green') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Free rewards flow failed: ${errorMessage}`, 'error') + throw new Error(`Free rewards redemption failed: ${errorMessage}`) + } + } + + /** + * Navigate to rewards redemption page with retry logic + * + * @param page Playwright page instance + * @returns Promise resolving when navigation completes + */ + private async navigateToRedeemPage(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Navigating to redeem page') + + try { + await page.goto('https://rewards.bing.com/redeem', { + waitUntil: 'domcontentloaded', + timeout: TIMEOUTS.DASHBOARD_WAIT * 2 + }) + + // Wait for page to fully load + await waitForNetworkIdle(page, { + timeoutMs: TIMEOUTS.DASHBOARD_WAIT, + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }).catch(() => { + // Network idle timeout is non-critical (page may still be usable) + }) + + // Dismiss any popups/overlays + await this.bot.browser.utils.tryDismissAllMessages(page) + + // Humanize page interaction + await this.bot.browser.utils.humanizePage(page) + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to navigate to redeem page: ${errorMessage}`) + } + } + + /** + * Find all rewards with 0 points cost on the page + * + * Uses class-based detection to support multiple locales (language-agnostic) + * Targets:
0 points
+ * + * @param page Playwright page instance + * @returns Array of reward elements (clickable cards) + */ + private async findFreeRewards(page: Page): Promise> { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Scanning for 0-point rewards') + + try { + // Wait for reward cards to load + await waitForElementSmart(page, '[mee-paragraph="para4"]', { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'attached', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }) + + // Find all price elements with class c-paragraph-4 + const priceElements = await page.locator('[mee-paragraph="para4"].c-paragraph-4').all() + + const freeRewards: Array<{ selector: string; title: string }> = [] + + for (const priceEl of priceElements) { + const priceText = await priceEl.textContent().catch(() => '') + + // Match "0 points" (with non-breaking space or regular space) + // Regex: /^0[\s\u00A0]*points?$/i (case-insensitive, flexible whitespace) + if (priceText && /^0[\s\u00A0]*points?$/i.test(priceText.trim())) { + // Find parent reward card (go up DOM tree to find clickable container) + const cardElement = priceEl.locator('xpath=ancestor::*[contains(@class, "card") or contains(@class, "reward")]').first() + + // Extract reward title for logging + const titleEl = cardElement.locator('[mee-paragraph="para2"], .reward-title, .card-title').first() + const title = await titleEl.textContent().catch(() => null) + const titleText = title ? title.trim() : 'Unknown Reward' + + // Get unique selector for this card (use data attributes if available) + const dataTestId = await cardElement.getAttribute('data-testid').catch(() => null) + const selector = dataTestId ? `[data-testid="${dataTestId}"]` : '[mee-paragraph="para4"]:has-text("0")' + + freeRewards.push({ selector, title: titleText }) + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Found free reward: "${titleText}"`) + } + } + + return freeRewards + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Error scanning for free rewards: ${errorMessage}`, 'warn') + return [] + } + } + + /** + * Redeem a single free reward + * + * @param page Playwright page instance + * @param reward Reward metadata (selector, title) + * @returns Promise resolving when redemption completes + */ + private async redeemSingleReward(page: Page, reward: { selector: string; title: string }): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Opening reward: "${reward.title}"`) + + // STEP 1: Click reward card to open detail page + await this.clickRewardCard(page, reward) + + // STEP 2: Click "Redeem" button on product detail page + await this.clickRedeemButton(page) + + // STEP 3: Wait for Cloudflare Turnstile CAPTCHA (if present) + await this.waitForCaptchaCompletion(page) + + // STEP 4: Confirm redemption + await this.confirmRedemption(page) + + // STEP 5: Verify success + await this.verifyRedemptionSuccess(page) + } + + /** + * Click reward card to open detail page + * + * @param page Playwright page instance + * @param reward Reward metadata + */ + private async clickRewardCard(page: Page, reward: { selector: string; title: string }): Promise { + try { + // Humanize before clicking + await this.bot.browser.utils.humanizePage(page) + await this.bot.utils.waitRandom(500, 1200) + + // Find and click the reward card + const cardResult = await waitForElementSmart(page, reward.selector, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }) + + if (!cardResult.found || !cardResult.element) { + throw new Error(`Reward card not found: ${reward.selector}`) + } + + await cardResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT }) + + // Wait for detail page to load + await waitForNetworkIdle(page, { + timeoutMs: TIMEOUTS.DASHBOARD_WAIT, + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }).catch(() => { + // Non-critical timeout + }) + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Reward detail page loaded') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to click reward card: ${errorMessage}`) + } + } + + /** + * Click "Redeem" button on product detail page + * + * Button structure (language-agnostic, class-based detection): + * + * REDEEM REWARD TEXT + * + * + */ + private async clickRedeemButton(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Clicking redeem button') + + try { + // Humanize before clicking + await this.bot.browser.utils.humanizePage(page) + await this.bot.utils.waitRandom(800, 1500) + + // Find redeem button (class-based: btn btn-primary card-button-height) + const buttonSelector = 'a.btn.btn-primary.card-button-height[href*="/redeem/checkout"]' + + const buttonResult = await waitForElementSmart(page, buttonSelector, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }) + + if (!buttonResult.found || !buttonResult.element) { + throw new Error('Redeem button not found on product detail page') + } + + await buttonResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT }) + + // Wait for checkout page to load + await waitForNetworkIdle(page, { + timeoutMs: TIMEOUTS.DASHBOARD_WAIT, + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }).catch(() => { + // Non-critical timeout + }) + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Navigated to checkout page') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to click redeem button: ${errorMessage}`) + } + } + + /** + * Wait for Cloudflare Turnstile CAPTCHA completion + * + * **STRATEGY:** + * - Detect CAPTCHA presence (#turnstile-widget iframe) + * - Wait up to 60s for user to manually solve CAPTCHA + * - Apply aggressive humanization (scrolling, mouse moves) during wait + * - Check for CAPTCHA disappearance (indicates completion) + * + * **USER EXPERIENCE:** + * - If CAPTCHA detected: bot pauses execution, logs warning, waits for user + * - User must solve CAPTCHA manually in browser window + * - Once solved, bot continues automatically + * + * @param page Playwright page instance + */ + private async waitForCaptchaCompletion(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Checking for Cloudflare Turnstile CAPTCHA') + + try { + // Check if CAPTCHA is present + const captchaWidget = page.locator('#turnstile-widget iframe').first() + const captchaVisible = await captchaWidget.isVisible({ timeout: 2000 }).catch(() => false) + + if (!captchaVisible) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'No CAPTCHA detected, proceeding') + return + } + + // CAPTCHA detected - apply aggressive humanization + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', '⚠️ Cloudflare Turnstile CAPTCHA detected! Applying aggressive humanization...', 'warn') + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'If CAPTCHA requires manual solving, please complete it in the browser window', 'warn') + + // Wait up to 60s for CAPTCHA completion with aggressive humanization + const captchaTimeout = 60000 // 60 seconds + const startTime = Date.now() + let attempts = 0 + + while (Date.now() - startTime < captchaTimeout) { + attempts++ + + // Apply aggressive humanization (scroll, mouse moves) + if (this.bot.config.humanization?.enabled !== false) { + // Random scroll + const scrollAmount = Math.floor(Math.random() * 300) + 100 + await page.mouse.wheel(0, scrollAmount).catch(() => { }) + await this.bot.utils.waitRandom(500, 1000) + + // Random mouse movement + const x = Math.floor(Math.random() * 200) + 100 + const y = Math.floor(Math.random() * 200) + 100 + await page.mouse.move(x, y, { steps: 5 }).catch(() => { }) + await this.bot.utils.waitRandom(300, 800) + } + + // Check if CAPTCHA is still visible + const stillVisible = await captchaWidget.isVisible({ timeout: 1000 }).catch(() => false) + if (!stillVisible) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `CAPTCHA completed after ${attempts} attempts (${Math.floor((Date.now() - startTime) / 1000)}s)`, 'log', 'green') + await this.bot.utils.wait(2000) // Wait for post-CAPTCHA processing + return + } + + // Wait before next check + await this.bot.utils.wait(1500) + } + + // CAPTCHA timeout - log error but continue (may fail at confirmation) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', '❌ CAPTCHA completion timeout after 60s. Redemption may fail.', 'error') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', `Error during CAPTCHA wait: ${errorMessage}`, 'warn') + // Continue anyway - CAPTCHA may not be blocking + } + } + + /** + * Confirm redemption on checkout page + * + * Button structure (class-based): + * + */ + private async confirmRedemption(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Confirming redemption') + + try { + // Humanize before clicking + await this.bot.browser.utils.humanizePage(page) + await this.bot.utils.waitRandom(1000, 2000) + + // Find confirm button (id-based for reliability) + const confirmButtonSelector = 'button#redeem-checkout-review-confirm, button.btn-primary.card-button-height' + + const buttonResult = await waitForElementSmart(page, confirmButtonSelector, { + initialTimeoutMs: 3000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 3000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }) + + if (!buttonResult.found || !buttonResult.element) { + throw new Error('Confirm button not found on checkout page. CAPTCHA may have blocked redemption.') + } + + await buttonResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT }) + + // Wait for confirmation page to load + await waitForNetworkIdle(page, { + timeoutMs: TIMEOUTS.DASHBOARD_WAIT * 2, // Extended timeout for processing + logFn: (msg) => this.bot.log(this.bot.isMobile, 'FREE-REWARDS', msg) + }).catch(() => { + // Non-critical timeout + }) + + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption confirmed') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to confirm redemption: ${errorMessage}`) + } + } + + /** + * Verify redemption success + * + * Checks for success indicators: + * - Success message on page + * - URL change to confirmation page + * - Absence of error messages + */ + private async verifyRedemptionSuccess(page: Page): Promise { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Verifying redemption success') + + try { + // Wait for page to stabilize + await this.bot.utils.wait(2000) + + // Check URL for success indicators + const currentUrl = page.url() + const isSuccessUrl = currentUrl.includes('orderconfirmation') || + currentUrl.includes('success') || + currentUrl.includes('confirmed') + + if (isSuccessUrl) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption successful (URL confirmed)') + return + } + + // Check for success message on page (class-based, language-agnostic) + const successIndicators = [ + '.success-message', + '.confirmation-message', + '[class*="success"]', + '[class*="confirmed"]' + ] + + for (const selector of successIndicators) { + const hasSuccess = await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false) + if (hasSuccess) { + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption successful (success message found)') + return + } + } + + // Check for error messages (indicates failure) + const errorIndicators = [ + '.error-message', + '[class*="error"]', + '[class*="failed"]' + ] + + for (const selector of errorIndicators) { + const hasError = await page.locator(selector).first().isVisible({ timeout: 1000 }).catch(() => false) + if (hasError) { + const errorText = await page.locator(selector).first().textContent().catch(() => null) + const errorMsg = errorText ? errorText.trim() : 'Unknown error' + throw new Error(`Redemption failed: ${errorMsg}`) + } + } + + // No clear success/error indicator - log warning but assume success + this.bot.log(this.bot.isMobile, 'FREE-REWARDS', 'Redemption status unclear (no explicit success/error indicator). Assuming success.', 'warn') + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error(`Redemption verification failed: ${errorMessage}`) + } + } + +} diff --git a/src/index.ts b/src/index.ts index e981ddd..1ce79e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ export class MicrosoftRewardsBot { public homePage!: Page public currentAccountEmail?: string public currentAccountRecoveryEmail?: string + public currentAccountPhoneNumber?: string public queryEngine?: QueryDiversityEngine public compromisedModeActive: boolean = false public compromisedReason?: string @@ -487,6 +488,7 @@ export class MicrosoftRewardsBot { this.currentAccountEmail = account.email // IMPROVED: Use centralized recovery email validation utility this.currentAccountRecoveryEmail = normalizeRecoveryEmail(account.recoveryEmail) + this.currentAccountPhoneNumber = account.phoneNumber const runNumber = (this.accountRunCounts.get(account.email) ?? 0) + 1 this.accountRunCounts.set(account.email, runNumber) log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`) diff --git a/src/interface/Account.ts b/src/interface/Account.ts index bc0f34b..fbeb789 100644 --- a/src/interface/Account.ts +++ b/src/interface/Account.ts @@ -7,6 +7,8 @@ export interface Account { totp?: string; /** Recovery email used during security challenge verification. Leave empty if not needed. */ recoveryEmail?: string; + /** Phone number associated with account (required for redeeming free rewards/gift cards) */ + phoneNumber?: string; proxy: AccountProxy; } diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 5ced095..44927fe 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -114,6 +114,7 @@ export interface ConfigWorkers { doMobileSearch: boolean; doDailyCheckIn: boolean; doReadToEarn: boolean; + doFreeRewards: boolean; // Automatically redeem 0-point gift cards (requires phoneNumber in account config) bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set }