Files
Microsoft-Rewards-Bot/src/functions/Workers.ts
LightZirconite 87166cf3ea refactor: remove obsolete test files for SummaryReporter, LoginStateDetector, MobileRetryTracker, QueryDiversityEngine, sanitize, search, smartWait, and webhookPreview
- Deleted tests for SummaryReporter, ensuring no redundant tests remain.
- Removed LoginStateDetector tests to streamline the validation process.
- Cleaned up MobileRetryTracker tests to eliminate unnecessary complexity.
- Purged QueryDiversityEngine tests to focus on essential functionalities.
- Eliminated sanitize tests to reduce clutter in the test suite.
- Removed search tests to enhance maintainability and clarity.
- Deleted smartWait tests to simplify the testing framework.
- Cleared webhookPreview tests to maintain a clean codebase.
2025-12-06 13:47:25 +01:00

335 lines
15 KiB
TypeScript

import { Page } from 'rebrowser-playwright'
import { TIMEOUTS } from '../constants'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index'
import { waitForElementSmart, waitForNetworkIdle } from '../util/browser/SmartWait'
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)`
} as const
// Activity processing delays (in milliseconds)
const ACTIVITY_DELAYS = {
THROTTLE_MIN: 800,
THROTTLE_MAX: 1400,
ACTIVITY_SPACING_MIN: 1200,
ACTIVITY_SPACING_MAX: 2600
} as const
export class Workers {
public bot: MicrosoftRewardsBot
private jobState: JobState
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
this.jobState = new JobState(this.bot.config)
}
// Daily Set
async doDailySet(page: Page, data: DashboardData) {
const today = this.bot.utils.getFormattedDate()
const todayData = data.dailySetPromotions[today]
const activitiesUncompleted = (todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [])
.filter(x => {
if (this.bot.config.jobState?.enabled === false) return true
const email = this.bot.currentAccountEmail || 'unknown'
return !this.jobState.isDone(email, today, x.offerId)
})
if (!activitiesUncompleted.length) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed')
return
}
// Solve Activities
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
await this.solveActivities(page, activitiesUncompleted)
// Mark as done to prevent duplicate work if checkpoints enabled
if (this.bot.config.jobState?.enabled !== false) {
const email = this.bot.currentAccountEmail || 'unknown'
for (const a of activitiesUncompleted) {
this.jobState.markDone(email, today, a.offerId)
}
}
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
// Optional: immediately run desktop search bundle
if (!this.bot.isMobile && this.bot.config.workers.bundleDailySetWithSearch && this.bot.config.workers.doDesktopSearch) {
try {
await this.bot.utils.waitRandom(1200, 2600)
await this.bot.activities.doSearch(page, data)
} catch (e) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', `Post-DailySet search failed: ${e instanceof Error ? e.message : e}`, 'warn')
}
}
}
// Punch Card
async doPunchCard(page: Page, data: DashboardData) {
const punchCardsUncompleted = data.punchCards?.filter(x => x.parentPromotion && !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
if (!punchCardsUncompleted.length) {
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Cards" have already been completed')
return
}
for (const punchCard of punchCardsUncompleted) {
// Ensure parentPromotion exists before proceeding
if (!punchCard.parentPromotion?.title) {
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Skipped punchcard "${punchCard.name}" | Reason: Parent promotion is missing!`, 'warn')
continue
}
// Get latest page for each card
page = await this.bot.browser.utils.getLatestTab(page)
const activitiesUncompleted = punchCard.childPromotions.filter(x => !x.complete) // Only return uncompleted activities
// Solve Activities
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
// Got to punch card index page in a new tab
await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL })
// IMPROVED: Smart wait replaces fixed 5s timeout with adaptive detection
await waitForNetworkIdle(page, {
timeoutMs: 5000,
logFn: (msg) => this.bot.log(this.bot.isMobile, 'PUNCH-CARD', msg)
}).catch(logError('PUNCH-CARD', 'Network idle wait timeout (non-critical)', this.bot.isMobile))
await this.solveActivities(page, activitiesUncompleted, punchCard)
page = await this.bot.browser.utils.getLatestTab(page)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
} else {
await this.bot.browser.func.goHome(page)
}
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
}
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Card" items have been completed')
}
// More Promotions
async doMorePromotions(page: Page, data: DashboardData) {
const morePromotions = data.morePromotions
// Check if there is a promotional item
if (data.promotionalItem) { // Convert and add the promotional item to the array
morePromotions.push(data.promotionalItem as unknown as MorePromotion)
}
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0 && x.exclusiveLockedFeatureStatus !== 'locked') ?? []
if (!activitiesUncompleted.length) {
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
return
}
// Solve Activities
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'Started solving "More Promotions" items')
page = await this.bot.browser.utils.getLatestTab(page)
await this.solveActivities(page, activitiesUncompleted)
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
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()
const retry = new Retry(this.bot.config.retryPolicy)
const throttle = new AdaptiveThrottler()
for (const activity of activities) {
try {
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
await this.applyThrottle(throttle, ACTIVITY_DELAYS.THROTTLE_MIN, ACTIVITY_DELAYS.THROTTLE_MAX)
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
await this.prepareActivityPage(activityPage, selector, throttle)
const typeLabel = this.bot.activities.getTypeLabel(activity)
if (typeLabel !== 'Unsupported') {
await this.executeActivity(activityPage, activity, selector, throttle, retry)
} else {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
}
await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity "${activity.title}" failed: ${message}`, 'error')
throttle.record(false)
}
}
}
private async manageTabLifecycle(page: Page, initialUrl: string): Promise<Page> {
page = await this.bot.browser.utils.getLatestTab(page)
const pages = page.context().pages()
if (pages.length > 3) {
await page.close()
page = await this.bot.browser.utils.getLatestTab(page)
}
if (page.url() !== initialUrl) {
await page.goto(initialUrl)
}
return page
}
private async buildActivitySelector(page: Page, activity: PromotionalItem | MorePromotion, punchCard?: PunchCard): Promise<string> {
if (punchCard) {
return await this.bot.browser.func.getPunchCardActivity(page, activity)
}
const name = activity.name.toLowerCase()
if (name.includes('membercenter') || name.includes('exploreonbing')) {
return ACTIVITY_SELECTORS.byName(activity.name)
}
// Validate offerId exists before using it in selector
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.`,
'warn'
)
return ACTIVITY_SELECTORS.byName(activity.name)
}
return ACTIVITY_SELECTORS.byOfferId(activity.offerId)
}
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
// IMPROVED: Smart wait replaces fixed 10s timeout with adaptive detection
await waitForNetworkIdle(page, {
timeoutMs: TIMEOUTS.DASHBOARD_WAIT,
logFn: (msg) => this.bot.log(this.bot.isMobile, 'WORKERS', msg)
}).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile))
await this.bot.browser.utils.humanizePage(page)
await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX)
}
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`)
// IMPROVED: Fast-fail for unavailable activities (1s+3s instead of 2s+5s)
const elementResult = await waitForElementSmart(page, selector, {
initialTimeoutMs: 1000,
extendedTimeoutMs: 3000,
state: 'attached',
logFn: (msg) => this.bot.log(this.bot.isMobile, 'ACTIVITY', msg)
})
if (!elementResult.found) {
this.bot.log(this.bot.isMobile, 'ACTIVITY', `[SKIP] Activity not available: "${activity.title}" (already completed or not offered today)`)
return // Skip this activity gracefully
}
// FIXED: Use locator from elementResult to ensure element exists before clicking
// This prevents indefinite hanging when element disappears between check and click
try {
if (elementResult.element) {
await elementResult.element.click({ timeout: TIMEOUTS.DASHBOARD_WAIT })
} else {
// Fallback to page.click with strict check if locator not available
await page.click(selector, { timeout: TIMEOUTS.DASHBOARD_WAIT, strict: true })
}
} catch (clickError) {
const errMsg = clickError instanceof Error ? clickError.message : String(clickError)
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Failed to click activity: ${errMsg}`, 'error')
throw new Error(`Activity click failed: ${errMsg}`)
}
page = await this.bot.browser.utils.getLatestTab(page)
// 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<never>((_, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Activity execution timeout after ${timeoutMs}ms`))
}, timeoutMs)
// Clean up timer if activity completes first
activityPromise.finally(() => clearTimeout(timer))
})
try {
await Promise.race([activityPromise, timeoutPromise])
throttle.record(true)
} catch (e) {
throttle.record(false)
throw e
}
}, () => true)
await this.bot.browser.utils.humanizePage(page)
}
private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise<void> {
const multiplier = throttle.getDelayMultiplier()
await this.bot.utils.waitRandom(
Math.floor(min * multiplier),
Math.floor(max * multiplier)
)
}
}