mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-11 09:46:16 +00:00
- 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.
335 lines
15 KiB
TypeScript
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)
|
|
)
|
|
}
|
|
|
|
} |