mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-23 22:51:02 +00:00
feat: Refactor and modularize flow handling for improved maintainability
- Extracted BuyModeHandler, DesktopFlow, MobileFlow, and SummaryReporter into separate modules for better organization and testability. - Enhanced type safety and added interfaces for various return types in Load, Logger, UserAgent, and flow modules. - Implemented comprehensive error handling and logging throughout the new modules. - Added unit tests for DesktopFlow, MobileFlow, and SummaryReporter to ensure functionality and correctness. - Updated existing utility functions to support new flow structures and improve code clarity.
This commit is contained in:
228
src/flows/BuyModeHandler.ts
Normal file
228
src/flows/BuyModeHandler.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Buy Mode Handler Module
|
||||
* Extracted from index.ts to improve maintainability and testability
|
||||
*
|
||||
* Handles automated Microsoft Store purchases:
|
||||
* - Browse available gift cards
|
||||
* - Select and purchase items
|
||||
* - Confirm transactions
|
||||
* - Track purchase history
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from 'playwright'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import type { Account } from '../interface/Account'
|
||||
|
||||
export interface PurchaseResult {
|
||||
success: boolean
|
||||
itemName?: string
|
||||
pointsSpent?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class BuyModeHandler {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute buy mode workflow
|
||||
* @param account Account to use for purchases
|
||||
* @returns Purchase result details
|
||||
*/
|
||||
async execute(account: Account): Promise<PurchaseResult> {
|
||||
this.bot.log(true, 'BUY-MODE', 'Starting buy mode workflow')
|
||||
|
||||
const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise<BrowserContext> } }).browserFactory
|
||||
const browser = await browserFactory.createBrowser(account.proxy, account.email)
|
||||
|
||||
try {
|
||||
this.bot.homePage = await browser.newPage()
|
||||
|
||||
this.bot.log(true, 'BUY-MODE', 'Browser started successfully')
|
||||
|
||||
// Login
|
||||
const login = (this.bot as unknown as { login: { login: (page: Page, email: string, password: string, totp?: string) => Promise<void> } }).login
|
||||
await login.login(this.bot.homePage, account.email, account.password, account.totp)
|
||||
|
||||
if (this.bot.compromisedModeActive) {
|
||||
this.bot.log(true, 'BUY-MODE', 'Account security check failed. Buy mode cancelled for safety.', 'warn', 'red')
|
||||
return {
|
||||
success: false,
|
||||
error: 'Security check failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to rewards store
|
||||
this.bot.log(true, 'BUY-MODE', 'Navigating to Microsoft Rewards store...')
|
||||
await this.bot.homePage.goto('https://rewards.microsoft.com/redeem/shop', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
await this.bot.homePage.waitForTimeout(3000)
|
||||
|
||||
// Get current points balance
|
||||
const pointsBalance = await this.getCurrentPoints()
|
||||
this.bot.log(true, 'BUY-MODE', `Current points balance: ${pointsBalance}`)
|
||||
|
||||
// Find available items
|
||||
const availableItems = await this.getAvailableItems(pointsBalance)
|
||||
|
||||
if (availableItems.length === 0) {
|
||||
this.bot.log(true, 'BUY-MODE', 'No items available within points budget', 'warn', 'yellow')
|
||||
return {
|
||||
success: false,
|
||||
error: 'No items available'
|
||||
}
|
||||
}
|
||||
|
||||
// Select first affordable item
|
||||
const selectedItem = availableItems[0]
|
||||
if (!selectedItem) {
|
||||
this.bot.log(true, 'BUY-MODE', 'No valid item found', 'warn', 'yellow')
|
||||
return {
|
||||
success: false,
|
||||
error: 'No valid item'
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log(true, 'BUY-MODE', `Attempting to purchase: ${selectedItem.name} (${selectedItem.points} points)`)
|
||||
|
||||
// Execute purchase
|
||||
const purchaseSuccess = await this.purchaseItem(selectedItem)
|
||||
|
||||
if (purchaseSuccess) {
|
||||
this.bot.log(true, 'BUY-MODE', `✅ Successfully purchased: ${selectedItem.name}`, 'log', 'green')
|
||||
return {
|
||||
success: true,
|
||||
itemName: selectedItem.name,
|
||||
pointsSpent: selectedItem.points
|
||||
}
|
||||
} else {
|
||||
this.bot.log(true, 'BUY-MODE', `❌ Failed to purchase: ${selectedItem.name}`, 'warn', 'red')
|
||||
return {
|
||||
success: false,
|
||||
error: 'Purchase confirmation failed'
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.bot.log(true, 'BUY-MODE', `Error during buy mode: ${message}`, 'error', 'red')
|
||||
return {
|
||||
success: false,
|
||||
error: message
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(browser, account.email)
|
||||
} catch (closeError) {
|
||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
||||
this.bot.log(true, 'BUY-MODE', `Failed to close browser: ${message}`, 'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current points balance from the page
|
||||
*/
|
||||
private async getCurrentPoints(): Promise<number> {
|
||||
try {
|
||||
const pointsText = await this.bot.homePage?.locator('[data-bi-id="RewardsHeader.CurrentPointsText"]').textContent()
|
||||
if (pointsText) {
|
||||
const points = parseInt(pointsText.replace(/[^0-9]/g, ''), 10)
|
||||
return isNaN(points) ? 0 : points
|
||||
}
|
||||
} catch {
|
||||
this.bot.log(true, 'BUY-MODE', 'Could not retrieve points balance, defaulting to 0', 'warn')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available items within budget
|
||||
*/
|
||||
private async getAvailableItems(maxPoints: number): Promise<Array<{ name: string; points: number; selector: string }>> {
|
||||
const items: Array<{ name: string; points: number; selector: string }> = []
|
||||
|
||||
try {
|
||||
const rewardCards = await this.bot.homePage?.locator('[data-bi-id^="RewardCard"]').all()
|
||||
|
||||
if (!rewardCards || rewardCards.length === 0) {
|
||||
this.bot.log(true, 'BUY-MODE', 'No reward cards found on page', 'warn')
|
||||
return items
|
||||
}
|
||||
|
||||
for (const card of rewardCards) {
|
||||
try {
|
||||
const nameElement = await card.locator('.reward-card-title').textContent()
|
||||
const pointsElement = await card.locator('.reward-card-points').textContent()
|
||||
|
||||
if (nameElement && pointsElement) {
|
||||
const name = nameElement.trim()
|
||||
const points = parseInt(pointsElement.replace(/[^0-9]/g, ''), 10)
|
||||
|
||||
if (!isNaN(points) && points <= maxPoints) {
|
||||
items.push({
|
||||
name,
|
||||
points,
|
||||
selector: `[data-bi-id="RewardCard"][data-title="${name}"]`
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid cards
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by points (cheapest first)
|
||||
items.sort((a, b) => a.points - b.points)
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(true, 'BUY-MODE', `Error finding available items: ${error}`, 'warn')
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute purchase for selected item
|
||||
*/
|
||||
private async purchaseItem(item: { name: string; points: number; selector: string }): Promise<boolean> {
|
||||
try {
|
||||
// Click on item card
|
||||
await this.bot.homePage?.locator(item.selector).click()
|
||||
await this.bot.homePage?.waitForTimeout(2000)
|
||||
|
||||
// Click redeem button
|
||||
const redeemButton = this.bot.homePage?.locator('[data-bi-id="RedeemButton"]')
|
||||
if (!redeemButton) {
|
||||
this.bot.log(true, 'BUY-MODE', 'Redeem button not found', 'warn')
|
||||
return false
|
||||
}
|
||||
|
||||
await redeemButton.click()
|
||||
await this.bot.homePage?.waitForTimeout(2000)
|
||||
|
||||
// Confirm purchase
|
||||
const confirmButton = this.bot.homePage?.locator('[data-bi-id="ConfirmRedeemButton"]')
|
||||
if (confirmButton) {
|
||||
await confirmButton.click()
|
||||
await this.bot.homePage?.waitForTimeout(3000)
|
||||
}
|
||||
|
||||
// Check for success message
|
||||
const successMessage = await this.bot.homePage?.locator('[data-bi-id="RedeemSuccess"]').isVisible({ timeout: 5000 })
|
||||
|
||||
return successMessage === true
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(true, 'BUY-MODE', `Error during purchase: ${error}`, 'warn')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/flows/DesktopFlow.ts
Normal file
155
src/flows/DesktopFlow.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Desktop Flow Module
|
||||
* Extracted from index.ts to improve maintainability and testability
|
||||
*
|
||||
* Handles desktop browser automation:
|
||||
* - Login and session management
|
||||
* - Daily set completion
|
||||
* - More promotions
|
||||
* - Punch cards
|
||||
* - Desktop searches
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from 'playwright'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import type { Account } from '../interface/Account'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
|
||||
export interface DesktopFlowResult {
|
||||
initialPoints: number
|
||||
collectedPoints: number
|
||||
}
|
||||
|
||||
export class DesktopFlow {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the full desktop automation flow for an account
|
||||
* @param account Account to process
|
||||
* @returns Points collected during the flow
|
||||
*/
|
||||
async run(account: Account): Promise<DesktopFlowResult> {
|
||||
this.bot.log(false, 'DESKTOP-FLOW', 'Starting desktop automation flow')
|
||||
|
||||
const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise<BrowserContext> } }).browserFactory
|
||||
const browser = await browserFactory.createBrowser(account.proxy, account.email)
|
||||
|
||||
let keepBrowserOpen = false
|
||||
|
||||
try {
|
||||
this.bot.homePage = await browser.newPage()
|
||||
|
||||
this.bot.log(false, 'DESKTOP-FLOW', 'Browser started successfully')
|
||||
|
||||
// Login into MS Rewards, then optionally stop if compromised
|
||||
const login = (this.bot as unknown as { login: { login: (page: Page, email: string, password: string, totp?: string) => Promise<void> } }).login
|
||||
await login.login(this.bot.homePage, account.email, account.password, account.totp)
|
||||
|
||||
if (this.bot.compromisedModeActive) {
|
||||
// User wants the page to remain open for manual recovery. Do not proceed to tasks.
|
||||
keepBrowserOpen = true
|
||||
const reason = this.bot.compromisedReason || 'security-issue'
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Account security check failed (${reason}). Browser kept open for manual review: ${account.email}`, 'warn', 'yellow')
|
||||
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.bot.config,
|
||||
'🔐 Security Check',
|
||||
`**Account:** ${account.email}\n**Status:** ${reason}\n**Action:** Browser kept open, activities paused`,
|
||||
undefined,
|
||||
0xFFAA00
|
||||
)
|
||||
} catch {/* ignore */}
|
||||
|
||||
// Save session for convenience, but do not close the browser
|
||||
try {
|
||||
await saveSessionData(this.bot.config.sessionPath, this.bot.homePage.context(), account.email, false)
|
||||
} catch (e) {
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||||
}
|
||||
|
||||
return { initialPoints: 0, collectedPoints: 0 }
|
||||
}
|
||||
|
||||
await this.bot.browser.func.goHome(this.bot.homePage)
|
||||
|
||||
const data = await this.bot.browser.func.getDashboardData()
|
||||
|
||||
const initial = data.userStatus.availablePoints
|
||||
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Current point count: ${initial}`)
|
||||
|
||||
const browserEarnablePoints = await this.bot.browser.func.getBrowserEarnablePoints()
|
||||
|
||||
// Tally all the desktop points
|
||||
const pointsCanCollect = browserEarnablePoints.dailySetPoints +
|
||||
browserEarnablePoints.desktopSearchPoints +
|
||||
browserEarnablePoints.morePromotionsPoints
|
||||
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `You can earn ${pointsCanCollect} points today`)
|
||||
|
||||
if (pointsCanCollect === 0) {
|
||||
// Extra diagnostic breakdown so users know WHY it's zero
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Breakdown (desktop): dailySet=${browserEarnablePoints.dailySetPoints} search=${browserEarnablePoints.desktopSearchPoints} promotions=${browserEarnablePoints.morePromotionsPoints}`)
|
||||
this.bot.log(false, 'DESKTOP-FLOW', 'All desktop earnable buckets are zero. This usually means: tasks already completed today OR the daily reset has not happened yet for your time zone. If you still want to force run activities set execution.runOnZeroPoints=true in config.', 'log', 'yellow')
|
||||
}
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, don't continue
|
||||
if (!this.bot.config.runOnZeroPoints && pointsCanCollect === 0) {
|
||||
this.bot.log(false, 'DESKTOP-FLOW', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
|
||||
return { initialPoints: initial, collectedPoints: 0 }
|
||||
}
|
||||
|
||||
// Open a new tab to where the tasks are going to be completed
|
||||
const workerPage = await browser.newPage()
|
||||
|
||||
// Go to homepage on worker page
|
||||
await this.bot.browser.func.goHome(workerPage)
|
||||
|
||||
// Complete daily set
|
||||
if (this.bot.config.workers.doDailySet) {
|
||||
const workers = (this.bot as unknown as { workers: { doDailySet: (page: Page, data: unknown) => Promise<void> } }).workers
|
||||
await workers.doDailySet(workerPage, data)
|
||||
}
|
||||
|
||||
// Complete more promotions
|
||||
if (this.bot.config.workers.doMorePromotions) {
|
||||
const workers = (this.bot as unknown as { workers: { doMorePromotions: (page: Page, data: unknown) => Promise<void> } }).workers
|
||||
await workers.doMorePromotions(workerPage, data)
|
||||
}
|
||||
|
||||
// Complete punch cards
|
||||
if (this.bot.config.workers.doPunchCards) {
|
||||
const workers = (this.bot as unknown as { workers: { doPunchCard: (page: Page, data: unknown) => Promise<void> } }).workers
|
||||
await workers.doPunchCard(workerPage, data)
|
||||
}
|
||||
|
||||
// Do desktop searches
|
||||
if (this.bot.config.workers.doDesktopSearch) {
|
||||
await this.bot.activities.doSearch(workerPage, data)
|
||||
}
|
||||
|
||||
// Fetch points BEFORE closing (avoid page closed reload error)
|
||||
const after = await this.bot.browser.func.getCurrentPoints().catch(() => initial)
|
||||
|
||||
return {
|
||||
initialPoints: initial,
|
||||
collectedPoints: (after - initial) || 0
|
||||
}
|
||||
} finally {
|
||||
if (!keepBrowserOpen) {
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(browser, account.email)
|
||||
} catch (closeError) {
|
||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
||||
this.bot.log(false, 'DESKTOP-FLOW', `Failed to close desktop context: ${message}`, 'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src/flows/MobileFlow.ts
Normal file
188
src/flows/MobileFlow.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Mobile Flow Module
|
||||
* Extracted from index.ts to improve maintainability and testability
|
||||
*
|
||||
* Handles mobile browser automation:
|
||||
* - Login and session management
|
||||
* - OAuth token acquisition
|
||||
* - Daily check-in
|
||||
* - Read to earn
|
||||
* - Mobile searches
|
||||
* - Mobile retry logic
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from 'playwright'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import type { Account } from '../interface/Account'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
import { MobileRetryTracker } from '../util/MobileRetryTracker'
|
||||
|
||||
export interface MobileFlowResult {
|
||||
initialPoints: number
|
||||
collectedPoints: number
|
||||
}
|
||||
|
||||
export class MobileFlow {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the full mobile automation flow for an account
|
||||
* @param account Account to process
|
||||
* @param retryTracker Retry tracker for mobile search failures
|
||||
* @returns Points collected during the flow
|
||||
*/
|
||||
async run(
|
||||
account: Account,
|
||||
retryTracker = new MobileRetryTracker(this.bot.config.searchSettings.retryMobileSearchAmount)
|
||||
): Promise<MobileFlowResult> {
|
||||
this.bot.log(true, 'MOBILE-FLOW', 'Starting mobile automation flow')
|
||||
|
||||
const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise<BrowserContext> } }).browserFactory
|
||||
const browser = await browserFactory.createBrowser(account.proxy, account.email)
|
||||
|
||||
let keepBrowserOpen = false
|
||||
let browserClosed = false
|
||||
|
||||
try {
|
||||
this.bot.homePage = await browser.newPage()
|
||||
|
||||
this.bot.log(true, 'MOBILE-FLOW', 'Browser started successfully')
|
||||
|
||||
// Login into MS Rewards, then respect compromised mode
|
||||
const login = (this.bot as unknown as { login: { login: (page: Page, email: string, password: string, totp?: string) => Promise<void>; getMobileAccessToken: (page: Page, email: string, totp?: string) => Promise<string> } }).login
|
||||
await login.login(this.bot.homePage, account.email, account.password, account.totp)
|
||||
|
||||
if (this.bot.compromisedModeActive) {
|
||||
keepBrowserOpen = true
|
||||
const reason = this.bot.compromisedReason || 'security-issue'
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Mobile security check failed (${reason}). Browser kept open for manual review: ${account.email}`, 'warn', 'yellow')
|
||||
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.bot.config,
|
||||
'🔐 Security Check (Mobile)',
|
||||
`**Account:** ${account.email}\n**Status:** ${reason}\n**Action:** Browser kept open, mobile activities paused`,
|
||||
undefined,
|
||||
0xFFAA00
|
||||
)
|
||||
} catch {/* ignore */}
|
||||
|
||||
try {
|
||||
await saveSessionData(this.bot.config.sessionPath, this.bot.homePage.context(), account.email, true)
|
||||
} catch (e) {
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||||
}
|
||||
|
||||
return { initialPoints: 0, collectedPoints: 0 }
|
||||
}
|
||||
|
||||
const accessToken = await login.getMobileAccessToken(this.bot.homePage, account.email, account.totp)
|
||||
await this.bot.browser.func.goHome(this.bot.homePage)
|
||||
|
||||
const data = await this.bot.browser.func.getDashboardData()
|
||||
const initialPoints = data.userStatus.availablePoints || 0
|
||||
|
||||
const browserEarnablePoints = await this.bot.browser.func.getBrowserEarnablePoints()
|
||||
const appEarnablePoints = await this.bot.browser.func.getAppEarnablePoints(accessToken)
|
||||
|
||||
const pointsCanCollect = browserEarnablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
|
||||
|
||||
this.bot.log(true, 'MOBILE-FLOW', `You can earn ${pointsCanCollect} points today (Browser: ${browserEarnablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
|
||||
|
||||
if (pointsCanCollect === 0) {
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Breakdown (mobile): browserSearch=${browserEarnablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
|
||||
this.bot.log(true, 'MOBILE-FLOW', 'All mobile earnable buckets are zero. Causes: mobile searches already maxed, daily set finished, or daily rollover not reached yet. You can force execution by setting execution.runOnZeroPoints=true.', 'log', 'yellow')
|
||||
}
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, don't continue
|
||||
if (!this.bot.config.runOnZeroPoints && pointsCanCollect === 0) {
|
||||
this.bot.log(true, 'MOBILE-FLOW', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
|
||||
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Do daily check in
|
||||
if (this.bot.config.workers.doDailyCheckIn) {
|
||||
await this.bot.activities.doDailyCheckIn(accessToken, data)
|
||||
}
|
||||
|
||||
// Do read to earn
|
||||
if (this.bot.config.workers.doReadToEarn) {
|
||||
await this.bot.activities.doReadToEarn(accessToken, data)
|
||||
}
|
||||
|
||||
// Do mobile searches
|
||||
const configuredRetries = Number(this.bot.config.searchSettings.retryMobileSearchAmount ?? 0)
|
||||
const maxMobileRetries = Number.isFinite(configuredRetries) ? configuredRetries : 0
|
||||
|
||||
if (this.bot.config.workers.doMobileSearch) {
|
||||
// If no mobile searches data found, stop (Does not always exist on new accounts)
|
||||
if (data.userStatus.counters.mobileSearch) {
|
||||
// Open a new tab to where the tasks are going to be completed
|
||||
const workerPage = await browser.newPage()
|
||||
|
||||
// Go to homepage on worker page
|
||||
await this.bot.browser.func.goHome(workerPage)
|
||||
|
||||
await this.bot.activities.doSearch(workerPage, data)
|
||||
|
||||
// Fetch current search points
|
||||
const mobileSearchPoints = (await this.bot.browser.func.getSearchPoints()).mobileSearch?.[0]
|
||||
|
||||
if (mobileSearchPoints && (mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0) {
|
||||
const shouldRetry = retryTracker.registerFailure()
|
||||
|
||||
if (!shouldRetry) {
|
||||
const exhaustedAttempts = retryTracker.getAttemptCount()
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Max retry limit of ${maxMobileRetries} reached after ${exhaustedAttempts} attempt(s). Exiting retry loop`, 'warn')
|
||||
} else {
|
||||
const attempt = retryTracker.getAttemptCount()
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Attempt ${attempt}/${maxMobileRetries}: Unable to complete mobile searches, bad User-Agent? Increase search delay? Retrying...`, 'log', 'yellow')
|
||||
|
||||
// Close mobile browser before retrying to release resources
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(browser, account.email)
|
||||
browserClosed = true
|
||||
} catch (closeError) {
|
||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Failed to close mobile context before retry: ${message}`, 'warn')
|
||||
}
|
||||
|
||||
// Create a new browser and try again with the same tracker
|
||||
return await this.run(account, retryTracker)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.bot.log(true, 'MOBILE-FLOW', 'Unable to fetch search points, your account is most likely too "new" for this! Try again later!', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
const afterPointAmount = await this.bot.browser.func.getCurrentPoints()
|
||||
|
||||
this.bot.log(true, 'MOBILE-FLOW', `The script collected ${afterPointAmount - initialPoints} points today`)
|
||||
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: (afterPointAmount - initialPoints) || 0
|
||||
}
|
||||
} finally {
|
||||
if (!keepBrowserOpen && !browserClosed) {
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(browser, account.email)
|
||||
browserClosed = true
|
||||
} catch (closeError) {
|
||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
||||
this.bot.log(true, 'MOBILE-FLOW', `Failed to close mobile context: ${message}`, 'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/flows/SummaryReporter.ts
Normal file
192
src/flows/SummaryReporter.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Summary Reporter Module
|
||||
* Extracted from index.ts to improve maintainability and testability
|
||||
*
|
||||
* Handles reporting and notifications:
|
||||
* - Points collection summaries
|
||||
* - Webhook notifications
|
||||
* - Ntfy push notifications
|
||||
* - Job state updates
|
||||
*/
|
||||
|
||||
import type { Config } from '../interface/Config'
|
||||
import { ConclusionWebhook } from '../util/ConclusionWebhook'
|
||||
import { JobState } from '../util/JobState'
|
||||
import { Ntfy } from '../util/Ntfy'
|
||||
|
||||
export interface AccountResult {
|
||||
email: string
|
||||
pointsEarned: number
|
||||
runDuration: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface SummaryData {
|
||||
accounts: AccountResult[]
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
totalPoints: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
}
|
||||
|
||||
export class SummaryReporter {
|
||||
private config: Config
|
||||
private jobState: JobState
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config
|
||||
this.jobState = new JobState(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send comprehensive summary via webhook
|
||||
*/
|
||||
async sendWebhookSummary(summary: SummaryData): Promise<void> {
|
||||
if (!this.config.webhook?.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const duration = Math.round((summary.endTime.getTime() - summary.startTime.getTime()) / 1000)
|
||||
const hours = Math.floor(duration / 3600)
|
||||
const minutes = Math.floor((duration % 3600) / 60)
|
||||
const seconds = duration % 60
|
||||
|
||||
const durationText = hours > 0
|
||||
? `${hours}h ${minutes}m ${seconds}s`
|
||||
: minutes > 0
|
||||
? `${minutes}m ${seconds}s`
|
||||
: `${seconds}s`
|
||||
|
||||
let description = `**Duration:** ${durationText}\n**Total Points:** ${summary.totalPoints}\n**Success:** ${summary.successCount}/${summary.accounts.length}\n\n`
|
||||
|
||||
// Add individual account results
|
||||
description += '**Account Results:**\n'
|
||||
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`
|
||||
}
|
||||
}
|
||||
|
||||
await ConclusionWebhook(
|
||||
this.config,
|
||||
'📊 Daily Run Complete',
|
||||
description,
|
||||
undefined,
|
||||
summary.failureCount > 0 ? 0xFF5555 : 0x00FF00
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[SUMMARY] Failed to send webhook:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification via Ntfy
|
||||
*/
|
||||
async sendPushNotification(summary: SummaryData): Promise<void> {
|
||||
if (!this.config.ntfy?.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('[SUMMARY] Failed to send Ntfy notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update job state with completion status
|
||||
*/
|
||||
async updateJobState(summary: SummaryData): Promise<void> {
|
||||
try {
|
||||
const day = summary.endTime.toISOString().split('T')?.[0]
|
||||
if (!day) return
|
||||
|
||||
for (const account of summary.accounts) {
|
||||
this.jobState.markAccountComplete(
|
||||
account.email,
|
||||
day,
|
||||
{
|
||||
totalCollected: account.pointsEarned,
|
||||
banned: false,
|
||||
errors: account.errors?.length ?? 0
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SUMMARY] Failed to update job state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and send comprehensive summary
|
||||
*/
|
||||
async generateReport(summary: SummaryData): Promise<void> {
|
||||
console.log('\n' + '═'.repeat(80))
|
||||
console.log('📊 EXECUTION SUMMARY')
|
||||
console.log('═'.repeat(80))
|
||||
|
||||
const duration = Math.round((summary.endTime.getTime() - summary.startTime.getTime()) / 1000)
|
||||
console.log(`\n⏱️ Duration: ${Math.floor(duration / 60)}m ${duration % 60}s`)
|
||||
console.log(`📈 Total Points Collected: ${summary.totalPoints}`)
|
||||
console.log(`✅ Successful Accounts: ${summary.successCount}/${summary.accounts.length}`)
|
||||
|
||||
if (summary.failureCount > 0) {
|
||||
console.log(`❌ Failed Accounts: ${summary.failureCount}`)
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(80))
|
||||
console.log('Account Breakdown:')
|
||||
console.log('─'.repeat(80))
|
||||
|
||||
for (const account of summary.accounts) {
|
||||
const status = account.errors?.length ? '❌ FAILED' : '✅ SUCCESS'
|
||||
const duration = Math.round(account.runDuration / 1000)
|
||||
|
||||
console.log(`\n${status} | ${account.email}`)
|
||||
console.log(` Points: ${account.pointsEarned} | Duration: ${duration}s`)
|
||||
|
||||
if (account.errors?.length) {
|
||||
console.log(` Error: ${account.errors[0]}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '═'.repeat(80) + '\n')
|
||||
|
||||
// Send notifications
|
||||
await Promise.all([
|
||||
this.sendWebhookSummary(summary),
|
||||
this.sendPushNotification(summary),
|
||||
this.updateJobState(summary)
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create summary data structure from account results
|
||||
*/
|
||||
createSummary(
|
||||
accounts: AccountResult[],
|
||||
startTime: Date,
|
||||
endTime: Date
|
||||
): SummaryData {
|
||||
const totalPoints = accounts.reduce((sum, acc) => sum + acc.pointsEarned, 0)
|
||||
const successCount = accounts.filter(acc => !acc.errors?.length).length
|
||||
const failureCount = accounts.length - successCount
|
||||
|
||||
return {
|
||||
accounts,
|
||||
startTime,
|
||||
endTime,
|
||||
totalPoints,
|
||||
successCount,
|
||||
failureCount
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user