import cluster from 'cluster' import type { Worker } from 'cluster' import type { Page } from 'playwright' import fs from 'fs' import path from 'path' import { spawn } from 'child_process' import { createInterface } from 'readline' import Browser from './browser/Browser' import BrowserFunc from './browser/BrowserFunc' import BrowserUtil from './browser/BrowserUtil' import { log } from './util/Logger' import { Util } from './util/Utils' import { loadAccounts, loadConfig, saveSessionData } from './util/Load' import Axios from './util/Axios' import Humanizer from './util/Humanizer' import { detectBanReason } from './util/BanDetector' import { QueryDiversityEngine } from './util/QueryDiversityEngine' import JobState from './util/JobState' import { StartupValidator } from './util/StartupValidator' import { MobileRetryTracker } from './util/MobileRetryTracker' import { SchedulerManager } from './util/SchedulerManager' import { BuyModeSelector, BuyModeMonitor } from './util/BuyMode' import { Login } from './functions/Login' import { Workers } from './functions/Workers' import { Activities } from './functions/Activities' import { Account } from './interface/Account' import { DISCORD } from './constants' // Main bot class export class MicrosoftRewardsBot { public log: typeof log public config public utils: Util public activities: Activities = new Activities(this) public browser: { func: BrowserFunc, utils: BrowserUtil } public humanizer: Humanizer public isMobile: boolean public homePage!: Page public currentAccountEmail?: string public currentAccountRecoveryEmail?: string public queryEngine?: QueryDiversityEngine public compromisedModeActive: boolean = false public compromisedReason?: string private activeWorkers: number private browserFactory: Browser = new Browser(this) private accounts: Account[] private workers: Workers private login = new Login(this) private buyModeEnabled: boolean = false private buyModeArgument?: string // Summary collection (per process) private accountSummaries: AccountSummary[] = [] private runId: string = Math.random().toString(36).slice(2) private bannedTriggered: { email: string; reason: string } | null = null private globalStandby: { active: boolean; reason?: string } = { active: false } private accountJobState?: JobState private accountRunCounts: Map = new Map() public axios!: Axios constructor(isMobile: boolean) { this.isMobile = isMobile this.log = log this.accounts = [] this.utils = new Util() this.config = loadConfig() if (this.config.jobState?.enabled !== false) { this.accountJobState = new JobState(this.config) } this.browser = { func: new BrowserFunc(this), utils: new BrowserUtil(this) } this.workers = new Workers(this) this.humanizer = new Humanizer(this.utils, this.config.humanization) this.activeWorkers = this.config.clusters if (this.config.queryDiversity?.enabled) { this.queryEngine = new QueryDiversityEngine({ sources: this.config.queryDiversity.sources, maxQueriesPerSource: this.config.queryDiversity.maxQueriesPerSource, cacheMinutes: this.config.queryDiversity.cacheMinutes }) } // Buy mode: CLI args take precedence over config const idx = process.argv.indexOf('-buy') if (idx >= 0) { this.buyModeEnabled = true this.buyModeArgument = process.argv[idx + 1] } else { // Fallback to config if no CLI flag const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined if (buyModeConfig?.enabled === true) { this.buyModeEnabled = true } } } public isBuyModeEnabled(): boolean { return this.buyModeEnabled === true } public getBuyModeTarget(): string | undefined { return this.buyModeArgument } async initialize() { this.accounts = loadAccounts() // Run comprehensive startup validation const validator = new StartupValidator() try { await validator.validate(this.config, this.accounts) } catch (error) { // Critical validation errors prevent startup const errorMsg = error instanceof Error ? error.message : String(error) log('main', 'VALIDATION', `Fatal validation error: ${errorMsg}`, 'error') throw error // Re-throw to stop execution } // Validation passed - continue with initialization // Initialize job state if (this.config.jobState?.enabled !== false) { this.accountJobState = new JobState(this.config) } // Setup or remove automatic scheduler based on config const scheduler = new SchedulerManager(this.config) await scheduler.setup() } private shouldSkipAccount(email: string, dayKey: string): boolean { if (!this.accountJobState) return false if (this.config.jobState?.skipCompletedAccounts === false) return false if ((this.config.passesPerRun ?? 1) > 1) return false if (this.isAccountSkipOverride()) return false return this.accountJobState.isAccountComplete(email, dayKey) } private persistAccountCompletion(email: string, dayKey: string, summary: AccountSummary): void { if (!this.accountJobState) return if (this.config.jobState?.skipCompletedAccounts === false) return if ((this.config.passesPerRun ?? 1) > 1) return if (this.isAccountSkipOverride()) return this.accountJobState.markAccountComplete(email, dayKey, { runId: this.runId, totalCollected: summary.totalCollected, banned: summary.banned?.status === true, errors: summary.errors.length }) } private isAccountSkipOverride(): boolean { const value = process.env.REWARDS_DISABLE_ACCOUNT_SKIP if (!value) return false const lower = value.toLowerCase() return value === '1' || lower === 'true' || lower === 'yes' } private async promptResetJobState(): Promise { // Skip prompt in non-interactive environments (Docker, CI, scheduled tasks) if (!process.stdin.isTTY) { log('main','TASK','Non-interactive environment detected - keeping job state', 'warn') return false } const rl = createInterface({ input: process.stdin, output: process.stdout }) return new Promise((resolve) => { rl.question('\n⚠️ Reset job state and run all accounts again? (y/N): ', (answer) => { rl.close() const trimmed = answer.trim().toLowerCase() resolve(trimmed === 'y' || trimmed === 'yes') }) }) } private resetAllJobStates(): void { if (!this.accountJobState) return const jobStateDir = this.accountJobState.getJobStateDir() if (!fs.existsSync(jobStateDir)) return const files = fs.readdirSync(jobStateDir).filter(f => f.endsWith('.json')) for (const file of files) { try { fs.unlinkSync(path.join(jobStateDir, file)) } catch { // Ignore errors } } } async run() { this.printBanner() log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`) // If buy mode is enabled, run single-account interactive session without automation if (this.buyModeEnabled) { await this.runBuyMode() return } // Only cluster when there's more than 1 cluster demanded if (this.config.clusters > 1) { if (cluster.isPrimary) { this.runMaster() } else { this.runWorker() } } else { await this.runTasks(this.accounts) } } /** Manual spending session: login, then leave control to user while we passively monitor points. */ private async runBuyMode() { try { await this.initialize() const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined const maxMinutes = buyModeConfig?.maxMinutes ?? 45 const selector = new BuyModeSelector(this.accounts) const selection = await selector.selectAccount(this.buyModeArgument, maxMinutes) if (!selection) { log('main', 'BUY-MODE', 'Buy mode cancelled: no account selected', 'warn') return } const { account, maxMinutes: sessionMaxMinutes } = selection log('main', 'BUY-MODE', `Buy mode ENABLED for ${account.email}. Opening 2 tabs: (1) monitor tab (auto-refresh), (2) your browsing tab`, 'log', 'green') log('main', 'BUY-MODE', `Session duration: ${sessionMaxMinutes} minutes. Monitor tab refreshes every ~10s. Use the other tab for your actions.`, 'log', 'yellow') this.isMobile = false this.axios = new Axios(account.proxy) const browser = await this.browserFactory.createBrowser(account.proxy, account.email) // Open the monitor tab FIRST so auto-refresh happens out of the way let monitor = await browser.newPage() await this.login.login(monitor, account.email, account.password, account.totp) await this.browser.func.goHome(monitor) this.log(false, 'BUY-MODE', 'Opened MONITOR tab (auto-refreshes to track points).', 'log', 'yellow') // Then open the user free-browsing tab SECOND so users don’t see the refreshes const page = await browser.newPage() await this.browser.func.goHome(page) this.log(false, 'BUY-MODE', 'Opened USER tab (use this one to redeem/purchase freely).', 'log', 'green') // Helper to recreate monitor tab if the user closes it const recreateMonitor = async () => { try { if (!monitor.isClosed()) await monitor.close() } catch { /* ignore */ } monitor = await browser.newPage() await this.browser.func.goHome(monitor) } // Helper to send an immediate spend notice via webhooks/NTFY const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => { try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') await ConclusionWebhook( this.config, 'πŸ’³ Spend Detected', `**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`, undefined, 0xFFAA00 ) } catch (e) { this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn') } } // Get initial points let initial = 0 try { const data = await this.browser.func.getDashboardData(monitor) initial = data.userStatus.availablePoints || 0 } catch {/* ignore */} const pointMonitor = new BuyModeMonitor(initial) this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Starting passive point monitoring (session: ${sessionMaxMinutes} min)`) // Passive watcher: poll points periodically without clicking. const start = Date.now() const endAt = start + sessionMaxMinutes * 60 * 1000 while (Date.now() < endAt) { await this.utils.wait(10000) // If monitor tab was closed by user, recreate it quietly try { if (monitor.isClosed()) { this.log(false, 'BUY-MODE', 'Monitor tab was closed; reopening in background...', 'warn') await recreateMonitor() } } catch (e) { this.log(false, 'BUY-MODE', `Failed to check/recreate monitor tab: ${e instanceof Error ? e.message : String(e)}`, 'warn') } try { const data = await this.browser.func.getDashboardData(monitor) const nowPts = data.userStatus.availablePoints || 0 const spendInfo = pointMonitor.checkSpending(nowPts) if (spendInfo) { this.log(false, 'BUY-MODE', `Detected spend: -${spendInfo.spent} points (current: ${spendInfo.current})`) await sendSpendNotice(spendInfo.spent, spendInfo.current, spendInfo.total) } } catch (err) { // If we lost the page context, recreate the monitor tab and continue const msg = err instanceof Error ? err.message : String(err) if (/Target closed|page has been closed|browser has been closed/i.test(msg)) { this.log(false, 'BUY-MODE', 'Monitor page closed or lost; recreating...', 'warn') try { await recreateMonitor() } catch (e) { this.log(false, 'BUY-MODE', `Failed to recreate monitor: ${e instanceof Error ? e.message : String(e)}`, 'warn') } } else { this.log(false, 'BUY-MODE', `Dashboard check error: ${msg}`, 'warn') } } } // Save cookies and close monitor; keep main page open for user until they close it themselves try { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch (e) { log(false, 'BUY-MODE', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn') } try { if (!monitor.isClosed()) await monitor.close() } catch (e) { log(false, 'BUY-MODE', `Failed to close monitor tab: ${e instanceof Error ? e.message : String(e)}`, 'warn') } // Send a final minimal conclusion webhook for this manual session const monitorSummary = pointMonitor.getSummary() const summary: AccountSummary = { email: account.email, durationMs: monitorSummary.duration, desktopCollected: 0, mobileCollected: 0, totalCollected: -monitorSummary.spent, // negative indicates spend initialTotal: monitorSummary.initial, endTotal: monitorSummary.current, errors: [], banned: { status: false, reason: '' } } await this.sendConclusion([summary]) this.log(false, 'BUY-MODE', 'Buy mode session finished (monitoring period ended). You can close the browser when done.') } catch (e) { this.log(false, 'BUY-MODE', `Error in buy mode: ${e instanceof Error ? e.message : String(e)}`, 'error') } } private printBanner() { if (this.config.clusters > 1 && !cluster.isPrimary) return const version = this.getVersion() const mode = this.buyModeEnabled ? 'Manual Mode' : 'Automated Mode' log('main', 'BANNER', `Microsoft Rewards Bot v${version} - ${mode}`) log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`) if (this.buyModeEnabled) { log('main', 'BANNER', `Target: ${this.buyModeArgument || 'Interactive selection'}`) } else { const upd = this.config.update || {} const updTargets: string[] = [] if (upd.git !== false) updTargets.push('Git') if (upd.docker) updTargets.push('Docker') if (updTargets.length > 0) { log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`) } } } private getVersion(): string { try { const pkgPath = path.join(__dirname, '../', 'package.json') if (fs.existsSync(pkgPath)) { const raw = fs.readFileSync(pkgPath, 'utf-8') const pkg = JSON.parse(raw) return pkg.version || '2.51.0' } } catch { /* ignore */ } return '2.51.0' } // Return summaries (used when clusters==1) public getSummaries() { return this.accountSummaries } private runMaster() { log('main', 'MAIN-PRIMARY', 'Primary process started') const totalAccounts = this.accounts.length // Validate accounts exist if (totalAccounts === 0) { log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn') process.exit(0) } // If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers. const workerCount = Math.min(this.config.clusters, totalAccounts) const accountChunks = this.utils.chunkArray(this.accounts, workerCount) // Reset activeWorkers to actual spawn count (constructor used raw clusters) this.activeWorkers = workerCount // Store worker-to-chunk mapping for crash recovery const workerChunkMap = new Map() for (let i = 0; i < workerCount; i++) { const worker = cluster.fork() const chunk = accountChunks[i] || [] // Validate chunk has accounts if (chunk.length === 0) { log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn') } // Store chunk mapping for crash recovery if (worker.id) { workerChunkMap.set(worker.id, chunk) } (worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk }) worker.on('message', (msg: unknown) => { const m = msg as { type?: string; data?: AccountSummary[] } if (m && m.type === 'summary' && Array.isArray(m.data)) { this.accountSummaries.push(...m.data) } }) } cluster.on('exit', (worker: Worker, code: number) => { this.activeWorkers -= 1 log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') // Optional: restart crashed worker (basic heuristic) if crashRecovery allows const cr = this.config.crashRecovery if (cr?.restartFailedWorker && code !== 0 && worker.id) { const attempts = (worker as { _restartAttempts?: number })._restartAttempts || 0 if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) { (worker as { _restartAttempts?: number })._restartAttempts = attempts + 1 log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn') const originalChunk = workerChunkMap.get(worker.id) const newW = cluster.fork() if (originalChunk && originalChunk.length > 0 && newW.id) { (newW as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk }) workerChunkMap.set(newW.id, originalChunk) workerChunkMap.delete(worker.id) log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`) } else { log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker', 'warn') } newW.on('message', (msg: unknown) => { const m = msg as { type?: string; data?: AccountSummary[] } if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data) }) } } // Check if all workers have exited if (this.activeWorkers === 0) { // All workers done -> send conclusion (if enabled), run optional auto-update, then exit (async () => { try { await this.sendConclusion(this.accountSummaries) } catch (e) { log('main', 'CONCLUSION', `Failed to send conclusion: ${e instanceof Error ? e.message : String(e)}`, 'warn') } try { const updateCode = await this.runAutoUpdate() if (updateCode === 0) { log('main', 'UPDATE', 'βœ… Update successful - next run will use new version', 'log', 'green') } } catch (e) { log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') } log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') process.exit(0) })() } }) } private runWorker() { log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`) // Receive the chunk of accounts from the master ;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => { await this.runTasks(chunk) }) } private async runTasks(accounts: Account[]) { // Check if all accounts are already completed and prompt user const accountDayKey = this.utils.getFormattedDate() const allCompleted = accounts.every(acc => this.shouldSkipAccount(acc.email, accountDayKey)) if (allCompleted && accounts.length > 0) { log('main','TASK',`All accounts already completed on ${accountDayKey}`, 'warn', 'yellow') const shouldReset = await this.promptResetJobState() if (shouldReset) { this.resetAllJobStates() log('main','TASK','Job state reset - proceeding with all accounts', 'log', 'green') } else { log('main','TASK','Keeping existing job state - exiting', 'log') return } } for (const account of accounts) { // If a global standby is active due to security/banned, stop processing further accounts if (this.globalStandby.active) { log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow') break } // Optional global stop after first ban if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) { log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn') break } const currentDayKey = this.utils.getFormattedDate() if (this.shouldSkipAccount(account.email, currentDayKey)) { log('main','TASK',`Skipping account ${account.email}: already completed on ${currentDayKey} (job-state resume)`, 'warn') continue } // Reset compromised state per account this.compromisedModeActive = false this.compromisedReason = undefined // If humanization allowed windows are configured, wait until within a window try { const windows: string[] | undefined = this.config?.humanization?.allowedWindows if (Array.isArray(windows) && windows.length > 0) { const waitMs = this.computeWaitForAllowedWindow(windows) if (waitMs > 0) { log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn') await new Promise(r => setTimeout(r, waitMs)) } } } catch {/* ignore */} this.currentAccountEmail = account.email this.currentAccountRecoveryEmail = (typeof account.recoveryEmail === 'string' && account.recoveryEmail.trim() !== '') ? account.recoveryEmail.trim() : undefined const runNumber = (this.accountRunCounts.get(account.email) ?? 0) + 1 this.accountRunCounts.set(account.email, runNumber) log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`) const accountStart = Date.now() let desktopInitial = 0 let mobileInitial = 0 let desktopCollected = 0 let mobileCollected = 0 const errors: string[] = [] const banned = { status: false, reason: '' } this.axios = new Axios(account.proxy) const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1' if (this.config.dryRun) { log('main', 'DRY-RUN', `Dry run: skipping automation for ${account.email}`) const summary: AccountSummary = { email: account.email, durationMs: 0, desktopCollected: 0, mobileCollected: 0, totalCollected: 0, initialTotal: 0, endTotal: 0, errors: [], banned } this.accountSummaries.push(summary) this.persistAccountCompletion(account.email, accountDayKey, summary) continue } if (this.config.parallel) { const mobileInstance = new MicrosoftRewardsBot(true) mobileInstance.axios = this.axios // Run both and capture results with detailed logging const desktopPromise = this.Desktop(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null }) const mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null }) const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise]) // Handle desktop result if (desktopResult.status === 'fulfilled' && desktopResult.value) { desktopInitial = desktopResult.value.initialPoints desktopCollected = desktopResult.value.collectedPoints } else if (desktopResult.status === 'rejected') { log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error') errors.push(formatFullError('desktop-rejected', desktopResult.reason, verbose)) } // Handle mobile result if (mobileResult.status === 'fulfilled' && mobileResult.value) { mobileInitial = mobileResult.value.initialPoints mobileCollected = mobileResult.value.collectedPoints } else if (mobileResult.status === 'rejected') { log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error') errors.push(formatFullError('mobile-rejected', mobileResult.reason, verbose)) } } else { // Sequential execution with safety checks this.isMobile = false const desktopResult = await this.Desktop(account).catch(e => { const msg = e instanceof Error ? e.message : String(e) log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null }) if (desktopResult) { desktopInitial = desktopResult.initialPoints desktopCollected = desktopResult.collectedPoints } if (!banned.status && !this.compromisedModeActive) { this.isMobile = true const mobileResult = await this.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null }) if (mobileResult) { mobileInitial = mobileResult.initialPoints mobileCollected = mobileResult.collectedPoints } } else { const why = banned.status ? 'banned status' : 'compromised status' log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn') } } const accountEnd = Date.now() const durationMs = accountEnd - accountStart const totalCollected = desktopCollected + mobileCollected // Sequential mode: desktop runs first, mobile starts with desktop's end points // Parallel mode: both start from same baseline, take minimum to avoid double-count const initialTotal = this.config.parallel ? Math.min(desktopInitial || Infinity, mobileInitial || Infinity) : (desktopInitial || mobileInitial || 0) const endTotal = initialTotal + totalCollected const summary: AccountSummary = { email: account.email, durationMs, desktopCollected, mobileCollected, totalCollected, initialTotal, endTotal, errors, banned } this.accountSummaries.push(summary) this.persistAccountCompletion(account.email, accountDayKey, summary) if (banned.status) { this.bannedTriggered = { email: account.email, reason: banned.reason } // Enter global standby: do not proceed to next accounts this.globalStandby = { active: true, reason: `banned:${banned.reason}` } await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`) } await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green') } await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green') // Extra diagnostic summary when verbose if (process.env.DEBUG_REWARDS_VERBOSE === '1') { for (const summary of this.accountSummaries) { log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`) } } // If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open if (this.compromisedModeActive || this.globalStandby.active) { log('main','SECURITY','Security alert active. Process kept alive for manual review. Press CTRL+C to exit when done.','warn','yellow') // Periodic heartbeat with cleanup on exit const standbyInterval = setInterval(() => { log('main','SECURITY','Standby mode active: sessions kept open for review...','warn','yellow') }, 5 * 60 * 1000) // Cleanup on process exit process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) }) process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) }) return } // If in worker mode (clusters>1) send summaries to primary if (this.config.clusters > 1 && !cluster.isPrimary) { if (process.send) { process.send({ type: 'summary', data: this.accountSummaries }) } } else { // Single process mode -> build and send conclusion directly await this.sendConclusion(this.accountSummaries) // After conclusion, run optional auto-update const updateResult = await this.runAutoUpdate().catch((e) => { log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') return 1 // Error code }) // If update was successful (code 0), restart the script to use the new version // This is critical for cron jobs - they need to apply updates immediately if (updateResult === 0) { log('main', 'UPDATE', 'βœ… Update successful - restarting with new version...', 'log', 'green') // On Raspberry Pi/Linux with cron, just exit - cron will handle next run // No need to restart immediately, next scheduled run will use new code log('main', 'UPDATE', 'Next scheduled run will use the updated code', 'log') } } process.exit() } /** Send immediate ban alert if configured. */ private async handleImmediateBanAlert(email: string, reason: string): Promise { try { const h = this.config?.humanization if (!h || h.immediateBanAlert === false) return const { ConclusionWebhook } = await import('./util/ConclusionWebhook') await ConclusionWebhook( this.config, '🚫 Ban Detected', `**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`, undefined, DISCORD.COLOR_RED ) } catch (e) { log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn') } } /** Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). Returns 0 if already inside. */ private computeWaitForAllowedWindow(windows: string[]): number { const now = new Date() const minsNow = now.getHours() * 60 + now.getMinutes() let nextStartMins: number | null = null for (const w of windows) { const [start, end] = w.split('-') if (!start || !end) continue const pStart = start.split(':').map(v=>parseInt(v,10)) const pEnd = end.split(':').map(v=>parseInt(v,10)) if (pStart.length !== 2 || pEnd.length !== 2) continue const sh = pStart[0]!, sm = pStart[1]! const eh = pEnd[0]!, em = pEnd[1]! if ([sh,sm,eh,em].some(n=>Number.isNaN(n))) continue const s = sh*60 + sm const e = eh*60 + em if (s <= e) { // same-day window if (minsNow >= s && minsNow <= e) return 0 if (minsNow < s) nextStartMins = Math.min(nextStartMins ?? s, s) } else { // wraps past midnight (e.g., 22:00-02:00) if (minsNow >= s || minsNow <= e) return 0 // next start today is s nextStartMins = Math.min(nextStartMins ?? s, s) } } const msPerMin = 60*1000 if (nextStartMins != null) { const targetTodayMs = (nextStartMins - minsNow) * msPerMin return targetTodayMs > 0 ? targetTodayMs : (24*60 + nextStartMins - minsNow) * msPerMin } // No valid windows parsed -> do not block return 0 } async Desktop(account: Account) { log(false,'FLOW','Desktop() invoked') const browser = await this.browserFactory.createBrowser(account.proxy, account.email) 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. 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') // Close desktop browser await this.browser.func.closeBrowser(browser, account.email) 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) } // Save cookies await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) // Fetch points BEFORE closing (avoid page closed reload error) const after = await this.browser.func.getCurrentPoints().catch(()=>initial) // Close desktop browser await this.browser.func.closeBrowser(browser, account.email) return { initialPoints: initial, collectedPoints: (after - initial) || 0 } } async Mobile( account: Account, retryTracker = new MobileRetryTracker(this.config.searchSettings.retryMobileSearchAmount) ): Promise<{ initialPoints: number; collectedPoints: number }> { log(true,'FLOW','Mobile() invoked') const browser = await this.browserFactory.createBrowser(account.proxy, account.email) 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) { 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') // Close mobile browser await this.browser.func.closeBrowser(browser, account.email) 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 await this.browser.func.closeBrowser(browser, account.email) // 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`) // Close mobile browser await this.browser.func.closeBrowser(browser, account.email) return { initialPoints: initialPoints, collectedPoints: (afterPointAmount - initialPoints) || 0 } } private async sendConclusion(summaries: AccountSummary[]) { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const cfg = this.config const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled) const ntfyEnabled = !!(cfg.ntfy && cfg.ntfy.enabled) const webhookEnabled = !!(cfg.webhook && cfg.webhook.enabled) const totalAccounts = summaries.length if (totalAccounts === 0) return let totalCollected = 0 let totalInitial = 0 let totalEnd = 0 let totalDuration = 0 let accountsWithErrors = 0 let successes = 0 // Calculate summary statistics for (const s of summaries) { totalCollected += s.totalCollected totalInitial += s.initialTotal totalEnd += s.endTotal totalDuration += s.durationMs if (s.errors.length) accountsWithErrors++ else successes++ } const avgDuration = totalDuration / totalAccounts const avgPointsPerAccount = Math.round(totalCollected / totalAccounts) // Read package version let version = 'unknown' try { const pkgPath = path.join(process.cwd(), 'package.json') if (fs.existsSync(pkgPath)) { const raw = fs.readFileSync(pkgPath, 'utf-8') const pkg = JSON.parse(raw) version = pkg.version || version } } catch { /* ignore */ } const formatNumber = (value: number) => value.toLocaleString() const formatSigned = (value: number) => `${value >= 0 ? '+' : ''}${formatNumber(value)}` const padText = (value: string, length: number) => { if (value.length >= length) { if (length <= 1) return value.slice(0, length) return value.length === length ? value : `${value.slice(0, length - 1)}…` } return value + ' '.repeat(length - value.length) } const buildAccountLine = (summary: AccountSummary): string => { const statusIcon = summary.banned?.status ? '🚫' : (summary.errors.length ? '⚠️' : 'βœ…') const email = padText(summary.email, 24) const total = padText(`${formatSigned(summary.totalCollected)} pts`, 13) const desktop = padText(`D:${formatSigned(summary.desktopCollected)}`, 13) const mobile = padText(`M:${formatSigned(summary.mobileCollected)}`, 13) const totals = padText(`${formatNumber(summary.initialTotal)}β†’${formatNumber(summary.endTotal)}`, 21) const duration = padText(formatDuration(summary.durationMs), 10) const extras: string[] = [] if (summary.banned?.status) extras.push(`BAN:${summary.banned.reason || 'detected'}`) if (summary.errors.length) extras.push(`ERR:${summary.errors.slice(0, 1).join(' | ')}`) const tail = extras.length ? ` | ${extras.join(' β€’ ')}` : '' return `${statusIcon} ${email} ${total} ${desktop}${mobile} ${totals} ${duration}${tail}` } const chunkLines = (lines: string[], maxLen = 900): string[][] => { const chunks: string[][] = [] let current: string[] = [] let currentLen = 0 for (const line of lines) { const nextLen = line.length + 1 if (current.length > 0 && currentLen + nextLen > maxLen) { chunks.push(current) current = [] currentLen = 0 } current.push(line) currentLen += nextLen } if (current.length) chunks.push(current) return chunks } const accountLines = summaries.map(buildAccountLine) const accountChunks = chunkLines(accountLines) const globalLines = [ `Total points: **${formatNumber(totalInitial)}** β†’ **${formatNumber(totalEnd)}** (${formatSigned(totalCollected)} pts)`, `Accounts: βœ… ${successes}${accountsWithErrors > 0 ? ` β€’ ⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`, `Average per account: **${formatSigned(avgPointsPerAccount)} pts** β€’ **${formatDuration(avgDuration)}**`, `Runtime: **${formatDuration(totalDuration)}**` ] const globalStatsValue = globalLines.join('\n') const fields: { name: string; value: string; inline?: boolean }[] = [ { name: 'πŸ“Š Summary', value: globalStatsValue, inline: false } ] if (accountChunks.length === 0) { fields.push({ name: 'πŸ“‹ Accounts', value: '_No results recorded_', inline: false }) } else { accountChunks.forEach((chunk, index) => { const name = accountChunks.length === 1 ? 'πŸ“‹ Accounts' : `πŸ“‹ Accounts (${index + 1}/${accountChunks.length})` fields.push({ name, value: ['```', ...chunk, '```'].join('\n'), inline: false }) }) } // Send webhook if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) { await ConclusionWebhook( cfg, 'βœ… Daily Run Complete', `**v${version}** β€’ ${this.runId}`, fields, accountsWithErrors > 0 ? DISCORD.COLOR_ORANGE : DISCORD.COLOR_GREEN ) } // Write local JSON report try { const fs = await import('fs') const path = await import('path') const now = new Date() const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}` const baseDir = path.join(process.cwd(), 'reports', day) if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }) const file = path.join(baseDir, `summary_${this.runId}.json`) const payload = { runId: this.runId, timestamp: now.toISOString(), totals: { totalCollected, totalInitial, totalEnd, totalDuration, totalAccounts, accountsWithErrors }, perAccount: summaries } fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8') log('main','REPORT',`Saved report to ${file}`) } catch (e) { log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn') } } // Run optional auto-update script based on configuration flags. private async runAutoUpdate(): Promise { const upd = this.config.update if (!upd) return 0 const scriptRel = upd.scriptPath || 'setup/update/update.mjs' const scriptAbs = path.join(process.cwd(), scriptRel) if (!fs.existsSync(scriptAbs)) return 0 const args: string[] = [] // Git update is enabled by default (unless explicitly set to false) if (upd.git !== false) args.push('--git') if (upd.docker) args.push('--docker') if (args.length === 0) return 0 // Run update script as a child process and capture exit code return new Promise((resolve) => { const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' }) child.on('close', (code) => resolve(code ?? 0)) child.on('error', () => resolve(1)) }) } /** Public entry-point to engage global security standby from other modules (idempotent). */ public async engageGlobalStandby(reason: string, email?: string): Promise { try { if (this.globalStandby.active) return this.globalStandby = { active: true, reason } const who = email || this.currentAccountEmail || 'unknown' await this.sendGlobalSecurityStandbyAlert(who, reason) } catch {/* ignore */} } /** Send a strong alert to all channels and mention @everyone when entering global security standby. */ private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise { try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') await ConclusionWebhook( this.config, '🚨 Critical Security Alert', `@everyone\n\n**Account:** ${email}\n**Issue:** ${reason}\n**Status:** All accounts paused pending review`, undefined, DISCORD.COLOR_RED ) } catch (e) { log('main','ALERT',`Failed to send alert: ${e instanceof Error ? e.message : e}`,'warn') } } } interface AccountSummary { email: string durationMs: number desktopCollected: number mobileCollected: number totalCollected: number initialTotal: number endTotal: number errors: string[] banned?: { status: boolean; reason: string } } function shortErr(e: unknown): string { if (e == null) return 'unknown' if (e instanceof Error) return e.message.substring(0, 120) const s = String(e) return s.substring(0, 120) } function formatFullError(label: string, e: unknown, verbose: boolean): string { const base = shortErr(e) if (verbose && e instanceof Error && e.stack) { return `${label}:${base} :: ${e.stack.split('\n').slice(0, 4).join(' | ')}` } return `${label}:${base}` } function formatDuration(ms: number): string { if (!ms || ms < 1000) return `${ms}ms` const sec = Math.floor(ms / 1000) const h = Math.floor(sec / 3600) const m = Math.floor((sec % 3600) / 60) const s = sec % 60 const parts: string[] = [] if (h) parts.push(`${h}h`) if (m) parts.push(`${m}m`) if (s) parts.push(`${s}s`) return parts.join(' ') || `${ms}ms` } async function main() { // Check for dashboard mode flag (standalone dashboard) if (process.argv.includes('-dashboard')) { const { startDashboardServer } = await import('./dashboard/server') const { dashboardState } = await import('./dashboard/state') log('main', 'DASHBOARD', 'Starting standalone dashboard server...') // Load and initialize accounts try { const accounts = loadAccounts() dashboardState.initializeAccounts(accounts.map(a => a.email)) log('main', 'DASHBOARD', `Initialized ${accounts.length} accounts in dashboard`) } catch (error) { log('main', 'DASHBOARD', 'Could not load accounts: ' + (error instanceof Error ? error.message : String(error)), 'warn') } startDashboardServer() return } const rewardsBot = new MicrosoftRewardsBot(false) const crashState = { restarts: 0 } const config = rewardsBot.config // Auto-start dashboard if enabled in config if (config.dashboard?.enabled) { const { DashboardServer } = await import('./dashboard/server') const { dashboardState } = await import('./dashboard/state') const port = config.dashboard.port || 3000 const host = config.dashboard.host || '127.0.0.1' // Override env vars with config values process.env.DASHBOARD_PORT = String(port) process.env.DASHBOARD_HOST = host // Initialize dashboard with accounts const accounts = loadAccounts() dashboardState.initializeAccounts(accounts.map(a => a.email)) const dashboardServer = new DashboardServer() dashboardServer.start() log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`) } const attachHandlers = () => { process.on('unhandledRejection', (reason: unknown) => { log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error') gracefulExit(1) }) process.on('uncaughtException', (err: Error) => { log('main','FATAL','UncaughtException: ' + err.message, 'error') gracefulExit(1) }) process.on('SIGTERM', () => gracefulExit(0)) process.on('SIGINT', () => gracefulExit(0)) } const gracefulExit = (code: number) => { if (config?.crashRecovery?.autoRestart && code !== 0) { const max = config.crashRecovery.maxRestarts ?? 2 if (crashState.restarts < max) { const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1) log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow') setTimeout(() => { crashState.restarts++ bootstrap() }, backoff) return } } process.exit(code) } const bootstrap = async () => { try { await rewardsBot.initialize() await rewardsBot.run() } catch (e) { log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error') gracefulExit(1) } } attachHandlers() await bootstrap() } // Start the bots if (require.main === module) { main().catch(error => { log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error') process.exit(1) }) }