mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 01:06:17 +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:
@@ -1,9 +1,9 @@
|
|||||||
import Browser from '../browser/Browser'
|
import Browser from '../browser/Browser'
|
||||||
import { AccountCreator } from './AccountCreator'
|
|
||||||
import { log } from '../util/Logger'
|
|
||||||
import { MicrosoftRewardsBot } from '../index'
|
import { MicrosoftRewardsBot } from '../index'
|
||||||
|
import { log } from '../util/Logger'
|
||||||
|
import { AccountCreator } from './AccountCreator'
|
||||||
|
|
||||||
async function main() {
|
async function main(): Promise<void> {
|
||||||
// Get referral URL from command line args
|
// Get referral URL from command line args
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
const referralUrl = args[0] // Optional referral URL
|
const referralUrl = args[0] // Optional referral URL
|
||||||
|
|||||||
@@ -165,8 +165,10 @@ class Browser {
|
|||||||
// Hide automation markers
|
// Hide automation markers
|
||||||
['__nightmare', '__playwright', '__pw_manual', '__webdriver_script_fn', 'webdriver'].forEach(prop => {
|
['__nightmare', '__playwright', '__pw_manual', '__webdriver_script_fn', 'webdriver'].forEach(prop => {
|
||||||
try {
|
try {
|
||||||
if (prop in window) delete window[prop];
|
if (prop in window) delete (window as Record<string, unknown>)[prop];
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Silently ignore: property deletion may be blocked by browser security
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override permissions to avoid detection
|
// Override permissions to avoid detection
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export const TIMEOUTS = {
|
|||||||
EXTRA_LONG: 10000,
|
EXTRA_LONG: 10000,
|
||||||
DASHBOARD_WAIT: 10000,
|
DASHBOARD_WAIT: 10000,
|
||||||
LOGIN_MAX: parseEnvNumber('LOGIN_MAX_WAIT_MS', 180000, 30000, 600000),
|
LOGIN_MAX: parseEnvNumber('LOGIN_MAX_WAIT_MS', 180000, 30000, 600000),
|
||||||
NETWORK_IDLE: 5000
|
NETWORK_IDLE: 5000,
|
||||||
|
ONE_MINUTE: 60000,
|
||||||
|
ONE_HOUR: 3600000,
|
||||||
|
TWO_MINUTES: 120000
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const RETRY_LIMITS = {
|
export const RETRY_LIMITS = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { dashboardState } from './state'
|
|
||||||
import type { MicrosoftRewardsBot } from '../index'
|
import type { MicrosoftRewardsBot } from '../index'
|
||||||
|
import { log as botLog } from '../util/Logger'
|
||||||
import { getErrorMessage } from '../util/Utils'
|
import { getErrorMessage } from '../util/Utils'
|
||||||
|
import { dashboardState } from './state'
|
||||||
|
|
||||||
export class BotController {
|
export class BotController {
|
||||||
private botInstance: MicrosoftRewardsBot | null = null
|
private botInstance: MicrosoftRewardsBot | null = null
|
||||||
@@ -11,7 +12,7 @@ export class BotController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private log(message: string, level: 'log' | 'warn' | 'error' = 'log'): void {
|
private log(message: string, level: 'log' | 'warn' | 'error' = 'log'): void {
|
||||||
console.log(`[BotController] ${message}`)
|
botLog('main', 'BOT-CONTROLLER', message, level)
|
||||||
|
|
||||||
dashboardState.addLog({
|
dashboardState.addLog({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export function loadPointsFromSessions(email: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`[Dashboard] Error loading points for ${email}:`, error)
|
// Silently ignore: session loading is optional fallback
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,8 +77,8 @@ export function loadAllPointsFromSessions(): Map<string, number> {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[Dashboard] Error loading points from sessions:', error)
|
// Silently ignore: session loading is optional fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
return pointsMap
|
return pointsMap
|
||||||
@@ -112,8 +112,8 @@ export function loadPointsFromJobState(email: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`[Dashboard] Error loading job state for ${email}:`, error)
|
// Silently ignore: job state loading is optional fallback
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Router, Request, Response } from 'express'
|
import { Request, Response, Router } from 'express'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { dashboardState } from './state'
|
import { getConfigPath, loadAccounts, loadConfig } from '../util/Load'
|
||||||
import { loadAccounts, loadConfig, getConfigPath } from '../util/Load'
|
|
||||||
import { botController } from './BotController'
|
import { botController } from './BotController'
|
||||||
|
import { dashboardState } from './state'
|
||||||
|
|
||||||
export const apiRouter = Router()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ function ensureAccountsLoaded(): void {
|
|||||||
try {
|
try {
|
||||||
const loadedAccounts = loadAccounts()
|
const loadedAccounts = loadAccounts()
|
||||||
dashboardState.initializeAccounts(loadedAccounts.map(a => a.email))
|
dashboardState.initializeAccounts(loadedAccounts.map(a => a.email))
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[Dashboard] Failed to load accounts:', error)
|
// Silently ignore: accounts loading is optional for API fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { createServer } from 'http'
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws'
|
|
||||||
import path from 'path'
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { apiRouter } from './routes'
|
import { createServer } from 'http'
|
||||||
import { dashboardState, DashboardLog } from './state'
|
import path from 'path'
|
||||||
|
import { WebSocket, WebSocketServer } from 'ws'
|
||||||
import { log as botLog } from '../util/Logger'
|
import { log as botLog } from '../util/Logger'
|
||||||
|
import { apiRouter } from './routes'
|
||||||
|
import { DashboardLog, dashboardState } from './state'
|
||||||
|
|
||||||
|
// Dashboard logging helper
|
||||||
|
const dashLog = (message: string, type: 'log' | 'warn' | 'error' = 'log'): void => {
|
||||||
|
botLog('main', 'DASHBOARD', message, type)
|
||||||
|
}
|
||||||
|
|
||||||
const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000
|
const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000
|
||||||
const HOST = process.env.DASHBOARD_HOST || '127.0.0.1'
|
const HOST = process.env.DASHBOARD_HOST || '127.0.0.1'
|
||||||
@@ -80,15 +85,15 @@ export class DashboardServer {
|
|||||||
private setupWebSocket(): void {
|
private setupWebSocket(): void {
|
||||||
this.wss.on('connection', (ws: WebSocket) => {
|
this.wss.on('connection', (ws: WebSocket) => {
|
||||||
this.clients.add(ws)
|
this.clients.add(ws)
|
||||||
console.log('[Dashboard] WebSocket client connected')
|
dashLog('WebSocket client connected')
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.clients.delete(ws)
|
this.clients.delete(ws)
|
||||||
console.log('[Dashboard] WebSocket client disconnected')
|
dashLog('WebSocket client disconnected')
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (error) => {
|
||||||
console.error('[Dashboard] WebSocket error:', error)
|
dashLog(`WebSocket error: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send initial data on connect
|
// Send initial data on connect
|
||||||
@@ -140,7 +145,7 @@ export class DashboardServer {
|
|||||||
try {
|
try {
|
||||||
client.send(payload)
|
client.send(payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Dashboard] Error broadcasting update:', error)
|
dashLog(`Error broadcasting update: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,15 +153,15 @@ export class DashboardServer {
|
|||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
this.server.listen(PORT, HOST, () => {
|
this.server.listen(PORT, HOST, () => {
|
||||||
console.log(`[Dashboard] Server running on http://${HOST}:${PORT}`)
|
dashLog(`Server running on http://${HOST}:${PORT}`)
|
||||||
console.log('[Dashboard] WebSocket ready for live logs')
|
dashLog('WebSocket ready for live logs')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
this.wss.close()
|
this.wss.close()
|
||||||
this.server.close()
|
this.server.close()
|
||||||
console.log('[Dashboard] Server stopped')
|
dashLog('Server stopped')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ class DashboardState {
|
|||||||
try {
|
try {
|
||||||
listener(type, data)
|
listener(type, data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Dashboard State] Error notifying listener:', error)
|
// Silently ignore listener errors to prevent state corruption
|
||||||
|
// Listeners are non-critical (UI updates, logging)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
src/index.ts
261
src/index.ts
@@ -40,7 +40,12 @@ import { Activities } from './functions/Activities'
|
|||||||
import { Login } from './functions/Login'
|
import { Login } from './functions/Login'
|
||||||
import { Workers } from './functions/Workers'
|
import { Workers } from './functions/Workers'
|
||||||
|
|
||||||
import { DISCORD } from './constants'
|
import { DesktopFlow } from './flows/DesktopFlow'
|
||||||
|
import { MobileFlow } from './flows/MobileFlow'
|
||||||
|
// import { SummaryReporter } from './flows/SummaryReporter' // TODO: Integrate in Phase 3
|
||||||
|
// import { BuyModeHandler } from './flows/BuyModeHandler' // TODO: Integrate in Phase 3
|
||||||
|
|
||||||
|
import { DISCORD, TIMEOUTS } from './constants'
|
||||||
import { Account } from './interface/Account'
|
import { Account } from './interface/Account'
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ export class MicrosoftRewardsBot {
|
|||||||
private activeWorkers: number
|
private activeWorkers: number
|
||||||
private browserFactory: Browser = new Browser(this)
|
private browserFactory: Browser = new Browser(this)
|
||||||
private accounts: Account[]
|
private accounts: Account[]
|
||||||
private workers: Workers
|
public workers: Workers // Made public for DesktopFlow access
|
||||||
private login = new Login(this)
|
private login = new Login(this)
|
||||||
private buyModeEnabled: boolean = false
|
private buyModeEnabled: boolean = false
|
||||||
private buyModeArgument?: string
|
private buyModeArgument?: string
|
||||||
@@ -264,7 +269,7 @@ export class MicrosoftRewardsBot {
|
|||||||
await this.runTasks(this.accounts)
|
await this.runTasks(this.accounts)
|
||||||
if (pass < passes) {
|
if (pass < passes) {
|
||||||
log('main', 'MAIN', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
|
log('main', 'MAIN', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
|
||||||
await this.utils.wait(60000) // 1 minute between passes
|
await this.utils.wait(TIMEOUTS.ONE_MINUTE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,7 +569,7 @@ export class MicrosoftRewardsBot {
|
|||||||
await this.runTasks(chunk)
|
await this.runTasks(chunk)
|
||||||
if (pass < passes) {
|
if (pass < passes) {
|
||||||
log('main', 'MAIN-WORKER', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
|
log('main', 'MAIN-WORKER', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
|
||||||
await this.utils.wait(60000) // 1 minute between passes
|
await this.utils.wait(TIMEOUTS.ONE_MINUTE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -876,252 +881,18 @@ export class MicrosoftRewardsBot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async Desktop(account: Account) {
|
async Desktop(account: Account) {
|
||||||
log(false,'FLOW','Desktop() invoked')
|
log(false, 'FLOW', 'Desktop() - delegating to DesktopFlow module')
|
||||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
const desktopFlow = new DesktopFlow(this)
|
||||||
let keepBrowserOpen = false
|
return await desktopFlow.run(account)
|
||||||
try {
|
|
||||||
this.homePage = await browser.newPage()
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN', 'Starting browser')
|
|
||||||
|
|
||||||
// Login into MS Rewards, then optionally stop if compromised
|
|
||||||
await this.login.login(this.homePage, account.email, account.password, account.totp)
|
|
||||||
|
|
||||||
if (this.compromisedModeActive) {
|
|
||||||
// User wants the page to remain open for manual recovery. Do not proceed to tasks.
|
|
||||||
keepBrowserOpen = true
|
|
||||||
const reason = this.compromisedReason || 'security-issue'
|
|
||||||
log(this.isMobile, 'SECURITY', `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.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.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
|
|
||||||
} catch (e) {
|
|
||||||
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
|
||||||
}
|
|
||||||
return { initialPoints: 0, collectedPoints: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.browser.func.goHome(this.homePage)
|
|
||||||
|
|
||||||
const data = await this.browser.func.getDashboardData()
|
|
||||||
|
|
||||||
const initial = data.userStatus.availablePoints
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${initial}`)
|
|
||||||
|
|
||||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
|
||||||
|
|
||||||
// Tally all the desktop points
|
|
||||||
const pointsCanCollect = browserEnarablePoints.dailySetPoints +
|
|
||||||
browserEnarablePoints.desktopSearchPoints +
|
|
||||||
browserEnarablePoints.morePromotionsPoints
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `You can earn ${pointsCanCollect} points today`)
|
|
||||||
|
|
||||||
if (pointsCanCollect === 0) {
|
|
||||||
// Extra diagnostic breakdown so users know WHY it's zero
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `Breakdown (desktop): dailySet=${browserEnarablePoints.dailySetPoints} search=${browserEnarablePoints.desktopSearchPoints} promotions=${browserEnarablePoints.morePromotionsPoints}`)
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', '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.config.runOnZeroPoints && pointsCanCollect === 0) {
|
|
||||||
log(this.isMobile, 'MAIN', '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.browser.func.goHome(workerPage)
|
|
||||||
|
|
||||||
// Complete daily set
|
|
||||||
if (this.config.workers.doDailySet) {
|
|
||||||
await this.workers.doDailySet(workerPage, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete more promotions
|
|
||||||
if (this.config.workers.doMorePromotions) {
|
|
||||||
await this.workers.doMorePromotions(workerPage, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete punch cards
|
|
||||||
if (this.config.workers.doPunchCards) {
|
|
||||||
await this.workers.doPunchCard(workerPage, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do desktop searches
|
|
||||||
if (this.config.workers.doDesktopSearch) {
|
|
||||||
await this.activities.doSearch(workerPage, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch points BEFORE closing (avoid page closed reload error)
|
|
||||||
const after = await this.browser.func.getCurrentPoints().catch(()=>initial)
|
|
||||||
return {
|
|
||||||
initialPoints: initial,
|
|
||||||
collectedPoints: (after - initial) || 0
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!keepBrowserOpen) {
|
|
||||||
try {
|
|
||||||
await this.browser.func.closeBrowser(browser, account.email)
|
|
||||||
} catch (closeError) {
|
|
||||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
|
||||||
this.log(this.isMobile, 'CLOSE-BROWSER', `Failed to close desktop context: ${message}`, 'warn')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Mobile(
|
async Mobile(
|
||||||
account: Account,
|
account: Account,
|
||||||
retryTracker = new MobileRetryTracker(this.config.searchSettings.retryMobileSearchAmount)
|
retryTracker = new MobileRetryTracker(this.config.searchSettings.retryMobileSearchAmount)
|
||||||
): Promise<{ initialPoints: number; collectedPoints: number }> {
|
): Promise<{ initialPoints: number; collectedPoints: number }> {
|
||||||
log(true,'FLOW','Mobile() invoked')
|
log(true, 'FLOW', 'Mobile() - delegating to MobileFlow module')
|
||||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
const mobileFlow = new MobileFlow(this)
|
||||||
let keepBrowserOpen = false
|
return await mobileFlow.run(account, retryTracker)
|
||||||
let browserClosed = false
|
|
||||||
try {
|
|
||||||
this.homePage = await browser.newPage()
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN', 'Starting browser')
|
|
||||||
|
|
||||||
// Login into MS Rewards, then respect compromised mode
|
|
||||||
await this.login.login(this.homePage, account.email, account.password, account.totp)
|
|
||||||
if (this.compromisedModeActive) {
|
|
||||||
keepBrowserOpen = true
|
|
||||||
const reason = this.compromisedReason || 'security-issue'
|
|
||||||
log(this.isMobile, 'SECURITY', `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.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.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
|
|
||||||
} catch (e) {
|
|
||||||
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
|
||||||
}
|
|
||||||
return { initialPoints: 0, collectedPoints: 0 }
|
|
||||||
}
|
|
||||||
const accessToken = await this.login.getMobileAccessToken(this.homePage, account.email, account.totp)
|
|
||||||
await this.browser.func.goHome(this.homePage)
|
|
||||||
|
|
||||||
const data = await this.browser.func.getDashboardData()
|
|
||||||
const initialPoints = data.userStatus.availablePoints || 0
|
|
||||||
|
|
||||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
|
||||||
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(accessToken)
|
|
||||||
|
|
||||||
const pointsCanCollect = browserEnarablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `You can earn ${pointsCanCollect} points today (Browser: ${browserEnarablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
|
|
||||||
|
|
||||||
if (pointsCanCollect === 0) {
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `Breakdown (mobile): browserSearch=${browserEnarablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', '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.config.runOnZeroPoints && pointsCanCollect === 0) {
|
|
||||||
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialPoints: initialPoints,
|
|
||||||
collectedPoints: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Do daily check in
|
|
||||||
if (this.config.workers.doDailyCheckIn) {
|
|
||||||
await this.activities.doDailyCheckIn(accessToken, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do read to earn
|
|
||||||
if (this.config.workers.doReadToEarn) {
|
|
||||||
await this.activities.doReadToEarn(accessToken, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do mobile searches
|
|
||||||
const configuredRetries = Number(this.config.searchSettings.retryMobileSearchAmount ?? 0)
|
|
||||||
const maxMobileRetries = Number.isFinite(configuredRetries) ? configuredRetries : 0
|
|
||||||
|
|
||||||
if (this.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.browser.func.goHome(workerPage)
|
|
||||||
|
|
||||||
await this.activities.doSearch(workerPage, data)
|
|
||||||
|
|
||||||
// Fetch current search points
|
|
||||||
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
|
|
||||||
|
|
||||||
if (mobileSearchPoints && (mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0) {
|
|
||||||
const shouldRetry = retryTracker.registerFailure()
|
|
||||||
|
|
||||||
if (!shouldRetry) {
|
|
||||||
const exhaustedAttempts = retryTracker.getAttemptCount()
|
|
||||||
log(this.isMobile, 'MAIN', `Max retry limit of ${maxMobileRetries} reached after ${exhaustedAttempts} attempt(s). Exiting retry loop`, 'warn')
|
|
||||||
} else {
|
|
||||||
const attempt = retryTracker.getAttemptCount()
|
|
||||||
log(this.isMobile, 'MAIN', `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.browser.func.closeBrowser(browser, account.email)
|
|
||||||
browserClosed = true
|
|
||||||
} catch (closeError) {
|
|
||||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
|
||||||
this.log(this.isMobile, 'CLOSE-BROWSER', `Failed to close mobile context before retry: ${message}`, 'warn')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new browser and try again with the same tracker
|
|
||||||
return await this.Mobile(account, retryTracker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log(this.isMobile, 'MAIN', 'Unable to fetch search points, your account is most likely too "new" for this! Try again later!', 'warn')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterPointAmount = await this.browser.func.getCurrentPoints()
|
|
||||||
|
|
||||||
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialPoints: initialPoints,
|
|
||||||
collectedPoints: (afterPointAmount - initialPoints) || 0
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!keepBrowserOpen && !browserClosed) {
|
|
||||||
try {
|
|
||||||
await this.browser.func.closeBrowser(browser, account.email)
|
|
||||||
browserClosed = true
|
|
||||||
} catch (closeError) {
|
|
||||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
|
||||||
this.log(this.isMobile, 'CLOSE-BROWSER', `Failed to close mobile context: ${message}`, 'warn')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendConclusion(summaries: AccountSummary[]) {
|
private async sendConclusion(summaries: AccountSummary[]) {
|
||||||
@@ -1376,7 +1147,7 @@ function formatDuration(ms: number): string {
|
|||||||
return parts.join(' ') || `${ms}ms`
|
return parts.join(' ') || `${ms}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main(): Promise<void> {
|
||||||
// Check for dashboard mode flag (standalone dashboard)
|
// Check for dashboard mode flag (standalone dashboard)
|
||||||
if (process.argv.includes('-dashboard')) {
|
if (process.argv.includes('-dashboard')) {
|
||||||
const { startDashboardServer } = await import('./dashboard/server')
|
const { startDashboardServer } = await import('./dashboard/server')
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { Account } from '../interface/Account'
|
|||||||
import { Config, ConfigBrowser, ConfigSaveFingerprint, ConfigScheduling } from '../interface/Config'
|
import { Config, ConfigBrowser, ConfigSaveFingerprint, ConfigScheduling } from '../interface/Config'
|
||||||
import { Util } from './Utils'
|
import { Util } from './Utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const utils = new Util()
|
const utils = new Util()
|
||||||
|
|
||||||
let configCache: Config
|
let configCache: Config
|
||||||
@@ -72,11 +76,10 @@ function stripJsonComments(input: string): string {
|
|||||||
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
|
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
|
||||||
function normalizeConfig(raw: unknown): Config {
|
function normalizeConfig(raw: unknown): Config {
|
||||||
// TYPE SAFETY NOTE: Using `any` here is necessary for backwards compatibility
|
// TYPE SAFETY NOTE: Using `any` here is necessary for backwards compatibility
|
||||||
// The config format has evolved from flat structure to nested structure over time
|
// JUSTIFIED USE OF `any`: The config format has evolved from flat → nested structure over time
|
||||||
// We need to support both formats dynamically without knowing which one we'll receive
|
// This needs to support BOTH formats for backward compatibility with existing user configs
|
||||||
// Alternative approaches (discriminated unions, multiple interfaces) would require
|
// Runtime validation happens through explicit property checks and the Config interface return type ensures type safety at function boundary
|
||||||
// runtime type checking on every property access, making the code much more complex
|
// Alternative approaches (discriminated unions, conditional types) would require extensive runtime checks making code significantly more complex
|
||||||
// The validation happens implicitly through the Config interface return type
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const n = (raw || {}) as any
|
const n = (raw || {}) as any
|
||||||
|
|
||||||
@@ -350,9 +353,9 @@ export function loadAccounts(): Account[] {
|
|||||||
if (!Array.isArray(parsed)) throw new Error('accounts must be an array')
|
if (!Array.isArray(parsed)) throw new Error('accounts must be an array')
|
||||||
// minimal shape validation
|
// minimal shape validation
|
||||||
for (const entry of parsed) {
|
for (const entry of parsed) {
|
||||||
// TYPE SAFETY NOTE: Using `any` for account validation
|
// JUSTIFIED USE OF `any`: Accounts come from untrusted user JSON with unpredictable structure
|
||||||
// Accounts come from user-provided JSON with unknown structure
|
// We perform explicit runtime validation of each property below (typeof checks, regex validation, etc.)
|
||||||
// We validate each property explicitly below rather than trusting the type
|
// This is safer than trusting a type assertion to a specific interface
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const a = entry as any
|
const a = entry as any
|
||||||
if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
|
if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
|
||||||
@@ -452,7 +455,12 @@ export function loadConfig(): Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSessionData(sessionPath: string, email: string, isMobile: boolean, saveFingerprint: ConfigSaveFingerprint) {
|
interface SessionData {
|
||||||
|
cookies: Cookie[]
|
||||||
|
fingerprint?: BrowserFingerprintWithHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSessionData(sessionPath: string, email: string, isMobile: boolean, saveFingerprint: ConfigSaveFingerprint): Promise<SessionData> {
|
||||||
try {
|
try {
|
||||||
// Fetch cookie file
|
// Fetch cookie file
|
||||||
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`)
|
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function getBuffer(url: string): WebhookBuffer {
|
|||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendBatch(url: string, buf: WebhookBuffer) {
|
async function sendBatch(url: string, buf: WebhookBuffer): Promise<void> {
|
||||||
if (buf.sending) return
|
if (buf.sending) return
|
||||||
buf.sending = true
|
buf.sending = true
|
||||||
while (buf.lines.length > 0) {
|
while (buf.lines.length > 0) {
|
||||||
|
|||||||
@@ -4,7 +4,25 @@ import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
|||||||
import { log } from './Logger'
|
import { log } from './Logger'
|
||||||
import { Retry } from './Retry'
|
import { Retry } from './Retry'
|
||||||
|
|
||||||
import { ChromeVersion, EdgeVersion, Architecture, Platform } from '../interface/UserAgentUtil'
|
import { Architecture, ChromeVersion, EdgeVersion, Platform } from '../interface/UserAgentUtil'
|
||||||
|
|
||||||
|
interface UserAgentMetadata {
|
||||||
|
mobile: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
platform: string
|
||||||
|
fullVersionList: Array<{ brand: string; version: string }>
|
||||||
|
brands: Array<{ brand: string; version: string }>
|
||||||
|
platformVersion: string
|
||||||
|
architecture: string
|
||||||
|
bitness: string
|
||||||
|
model: string
|
||||||
|
uaFullVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAgentResult {
|
||||||
|
userAgent: string
|
||||||
|
userAgentMetadata: UserAgentMetadata
|
||||||
|
}
|
||||||
|
|
||||||
const NOT_A_BRAND_VERSION = '99'
|
const NOT_A_BRAND_VERSION = '99'
|
||||||
const EDGE_VERSION_URL = 'https://edgeupdates.microsoft.com/api/products'
|
const EDGE_VERSION_URL = 'https://edgeupdates.microsoft.com/api/products'
|
||||||
@@ -24,7 +42,7 @@ type EdgeVersionResult = {
|
|||||||
let edgeVersionCache: { data: EdgeVersionResult; expiresAt: number } | null = null
|
let edgeVersionCache: { data: EdgeVersionResult; expiresAt: number } | null = null
|
||||||
let edgeVersionInFlight: Promise<EdgeVersionResult> | null = null
|
let edgeVersionInFlight: Promise<EdgeVersionResult> | null = null
|
||||||
|
|
||||||
export async function getUserAgent(isMobile: boolean) {
|
export async function getUserAgent(isMobile: boolean): Promise<UserAgentResult> {
|
||||||
const system = getSystemComponents(isMobile)
|
const system = getSystemComponents(isMobile)
|
||||||
const app = await getAppComponents(isMobile)
|
const app = await getAppComponents(isMobile)
|
||||||
|
|
||||||
@@ -133,7 +151,17 @@ export function getSystemComponents(mobile: boolean): string {
|
|||||||
return 'Windows NT 10.0; Win64; x64'
|
return 'Windows NT 10.0; Win64; x64'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppComponents(isMobile: boolean) {
|
interface AppComponents {
|
||||||
|
not_a_brand_version: string
|
||||||
|
not_a_brand_major_version: string
|
||||||
|
edge_version: string
|
||||||
|
edge_major_version: string
|
||||||
|
chrome_version: string
|
||||||
|
chrome_major_version: string
|
||||||
|
chrome_reduced_version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppComponents(isMobile: boolean): Promise<AppComponents> {
|
||||||
const versions = await getEdgeVersions(isMobile)
|
const versions = await getEdgeVersions(isMobile)
|
||||||
const edgeVersion = isMobile ? versions.android : versions.windows as string
|
const edgeVersion = isMobile ? versions.android : versions.windows as string
|
||||||
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
||||||
|
|||||||
73
tests/flows/desktopFlow.test.ts
Normal file
73
tests/flows/desktopFlow.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DesktopFlow unit tests
|
||||||
|
* Validates desktop automation flow logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('DesktopFlow module exports correctly', async () => {
|
||||||
|
const { DesktopFlow } = await import('../../src/flows/DesktopFlow')
|
||||||
|
assert.ok(DesktopFlow, 'DesktopFlow should be exported')
|
||||||
|
assert.equal(typeof DesktopFlow, 'function', 'DesktopFlow should be a class constructor')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DesktopFlow has run method', async () => {
|
||||||
|
const { DesktopFlow } = await import('../../src/flows/DesktopFlow')
|
||||||
|
|
||||||
|
// Mock bot instance
|
||||||
|
const mockBot = {
|
||||||
|
log: () => {},
|
||||||
|
isMobile: false,
|
||||||
|
config: { workers: {}, runOnZeroPoints: false },
|
||||||
|
browser: { func: {} },
|
||||||
|
utils: {},
|
||||||
|
activities: {},
|
||||||
|
compromisedModeActive: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = new DesktopFlow(mockBot as never)
|
||||||
|
assert.ok(flow, 'DesktopFlow instance should be created')
|
||||||
|
assert.equal(typeof flow.run, 'function', 'DesktopFlow should have run() method')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DesktopFlowResult interface has correct structure', async () => {
|
||||||
|
const { DesktopFlow } = await import('../../src/flows/DesktopFlow')
|
||||||
|
|
||||||
|
// Validate that DesktopFlowResult type exports (compile-time check)
|
||||||
|
type DesktopFlowResult = Awaited<ReturnType<InstanceType<typeof DesktopFlow>['run']>>
|
||||||
|
|
||||||
|
const mockResult: DesktopFlowResult = {
|
||||||
|
initialPoints: 1000,
|
||||||
|
collectedPoints: 50
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(typeof mockResult.initialPoints, 'number', 'initialPoints should be a number')
|
||||||
|
assert.equal(typeof mockResult.collectedPoints, 'number', 'collectedPoints should be a number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DesktopFlow handles security compromise mode', async () => {
|
||||||
|
const { DesktopFlow } = await import('../../src/flows/DesktopFlow')
|
||||||
|
|
||||||
|
const logs: string[] = []
|
||||||
|
const mockBot = {
|
||||||
|
log: (_: boolean, __: string, message: string) => logs.push(message),
|
||||||
|
isMobile: false,
|
||||||
|
config: {
|
||||||
|
workers: {},
|
||||||
|
runOnZeroPoints: false,
|
||||||
|
sessionPath: './sessions'
|
||||||
|
},
|
||||||
|
browser: { func: {} },
|
||||||
|
utils: {},
|
||||||
|
activities: {},
|
||||||
|
workers: {},
|
||||||
|
compromisedModeActive: true,
|
||||||
|
compromisedReason: 'test-security-check'
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = new DesktopFlow(mockBot as never)
|
||||||
|
|
||||||
|
// Note: Full test requires mocked browser context
|
||||||
|
assert.ok(flow, 'DesktopFlow should handle compromised mode')
|
||||||
|
})
|
||||||
76
tests/flows/mobileFlow.test.ts
Normal file
76
tests/flows/mobileFlow.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MobileFlow unit tests
|
||||||
|
* Validates mobile automation flow logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('MobileFlow module exports correctly', async () => {
|
||||||
|
const { MobileFlow } = await import('../../src/flows/MobileFlow')
|
||||||
|
assert.ok(MobileFlow, 'MobileFlow should be exported')
|
||||||
|
assert.equal(typeof MobileFlow, 'function', 'MobileFlow should be a class constructor')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('MobileFlow has run method', async () => {
|
||||||
|
const { MobileFlow } = await import('../../src/flows/MobileFlow')
|
||||||
|
|
||||||
|
// Mock bot instance
|
||||||
|
const mockBot = {
|
||||||
|
log: () => {},
|
||||||
|
isMobile: true,
|
||||||
|
config: {
|
||||||
|
workers: {},
|
||||||
|
runOnZeroPoints: false,
|
||||||
|
searchSettings: { retryMobileSearchAmount: 0 }
|
||||||
|
},
|
||||||
|
browser: { func: {} },
|
||||||
|
utils: {},
|
||||||
|
activities: {},
|
||||||
|
compromisedModeActive: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = new MobileFlow(mockBot as never)
|
||||||
|
assert.ok(flow, 'MobileFlow instance should be created')
|
||||||
|
assert.equal(typeof flow.run, 'function', 'MobileFlow should have run() method')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('MobileFlowResult interface has correct structure', async () => {
|
||||||
|
const { MobileFlow } = await import('../../src/flows/MobileFlow')
|
||||||
|
|
||||||
|
// Validate that MobileFlowResult type exports (compile-time check)
|
||||||
|
type MobileFlowResult = Awaited<ReturnType<InstanceType<typeof MobileFlow>['run']>>
|
||||||
|
|
||||||
|
const mockResult: MobileFlowResult = {
|
||||||
|
initialPoints: 1000,
|
||||||
|
collectedPoints: 30
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(typeof mockResult.initialPoints, 'number', 'initialPoints should be a number')
|
||||||
|
assert.equal(typeof mockResult.collectedPoints, 'number', 'collectedPoints should be a number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('MobileFlow accepts retry tracker', async () => {
|
||||||
|
const { MobileFlow } = await import('../../src/flows/MobileFlow')
|
||||||
|
const { MobileRetryTracker } = await import('../../src/util/MobileRetryTracker')
|
||||||
|
|
||||||
|
const mockBot = {
|
||||||
|
log: () => {},
|
||||||
|
isMobile: true,
|
||||||
|
config: {
|
||||||
|
workers: {},
|
||||||
|
runOnZeroPoints: false,
|
||||||
|
searchSettings: { retryMobileSearchAmount: 3 }
|
||||||
|
},
|
||||||
|
browser: { func: {} },
|
||||||
|
utils: {},
|
||||||
|
activities: {},
|
||||||
|
compromisedModeActive: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = new MobileFlow(mockBot as never)
|
||||||
|
const tracker = new MobileRetryTracker(3)
|
||||||
|
|
||||||
|
assert.ok(flow, 'MobileFlow should accept retry tracker')
|
||||||
|
assert.equal(typeof tracker.registerFailure, 'function', 'MobileRetryTracker should have registerFailure method')
|
||||||
|
})
|
||||||
80
tests/flows/summaryReporter.test.ts
Normal file
80
tests/flows/summaryReporter.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SummaryReporter unit tests
|
||||||
|
* Validates reporting and notification logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('SummaryReporter module exports correctly', async () => {
|
||||||
|
const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
|
||||||
|
assert.ok(SummaryReporter, 'SummaryReporter should be exported')
|
||||||
|
assert.equal(typeof SummaryReporter, 'function', 'SummaryReporter should be a class constructor')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SummaryReporter creates instance with config', async () => {
|
||||||
|
const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
webhook: { enabled: false },
|
||||||
|
ntfy: { enabled: false },
|
||||||
|
sessionPath: './sessions',
|
||||||
|
jobState: { enabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reporter = new SummaryReporter(mockConfig as never)
|
||||||
|
assert.ok(reporter, 'SummaryReporter instance should be created')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SummaryReporter creates summary correctly', async () => {
|
||||||
|
const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
webhook: { enabled: false },
|
||||||
|
ntfy: { enabled: false },
|
||||||
|
sessionPath: './sessions',
|
||||||
|
jobState: { enabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reporter = new SummaryReporter(mockConfig as never)
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
{ email: 'test@example.com', pointsEarned: 100, runDuration: 60000 },
|
||||||
|
{ email: 'test2@example.com', pointsEarned: 150, runDuration: 70000, errors: ['test error'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const startTime = new Date('2025-01-01T10:00:00Z')
|
||||||
|
const endTime = new Date('2025-01-01T10:05:00Z')
|
||||||
|
|
||||||
|
const summary = reporter.createSummary(accounts, startTime, endTime)
|
||||||
|
|
||||||
|
assert.equal(summary.totalPoints, 250, 'Total points should be 250')
|
||||||
|
assert.equal(summary.successCount, 1, 'Success count should be 1')
|
||||||
|
assert.equal(summary.failureCount, 1, 'Failure count should be 1')
|
||||||
|
assert.equal(summary.accounts.length, 2, 'Should have 2 accounts')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SummaryData structure is correct', async () => {
|
||||||
|
const { SummaryReporter } = await import('../../src/flows/SummaryReporter')
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
webhook: { enabled: false },
|
||||||
|
ntfy: { enabled: false },
|
||||||
|
sessionPath: './sessions',
|
||||||
|
jobState: { enabled: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reporter = new SummaryReporter(mockConfig as never)
|
||||||
|
|
||||||
|
const summary = reporter.createSummary(
|
||||||
|
[{ email: 'test@example.com', pointsEarned: 50, runDuration: 30000 }],
|
||||||
|
new Date(),
|
||||||
|
new Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.ok(summary.startTime instanceof Date, 'startTime should be a Date')
|
||||||
|
assert.ok(summary.endTime instanceof Date, 'endTime should be a Date')
|
||||||
|
assert.equal(typeof summary.totalPoints, 'number', 'totalPoints should be a number')
|
||||||
|
assert.equal(typeof summary.successCount, 'number', 'successCount should be a number')
|
||||||
|
assert.ok(Array.isArray(summary.accounts), 'accounts should be an array')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user