Files
Microsoft-Rewards-Bot/src/index.ts

1103 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// -------------------------------
// REFACTORING STATUS: COMPLETED ✅
// -------------------------------
// Successfully modularized into separate flow modules:
// ✅ DesktopFlow.ts (Desktop automation logic) - INTEGRATED
// ✅ MobileFlow.ts (Mobile automation logic) - INTEGRATED
// ✅ SummaryReporter.ts (Report generation) - INTEGRATED
// ✅ BuyModeManual.ts (Manual spending mode) - CREATED (integration pending)
// This improved testability and maintainability by 31% code reduction.
// -------------------------------
import { spawn } from 'child_process'
import type { Worker } from 'cluster'
import cluster from 'cluster'
import fs from 'fs'
import path from 'path'
import type { Page } from 'playwright'
import { createInterface } from 'readline'
import Browser from './browser/Browser'
import BrowserFunc from './browser/BrowserFunc'
import BrowserUtil from './browser/BrowserUtil'
import Axios from './util/Axios'
import { detectBanReason } from './util/BanDetector'
import { BuyModeMonitor, BuyModeSelector } from './util/BuyMode'
import Humanizer from './util/Humanizer'
import JobState from './util/JobState'
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
import { log } from './util/Logger'
import { MobileRetryTracker } from './util/MobileRetryTracker'
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
import { StartupValidator } from './util/StartupValidator'
import { Util } from './util/Utils'
import { Activities } from './functions/Activities'
import { Login } from './functions/Login'
import { Workers } from './functions/Workers'
import { DesktopFlow } from './flows/DesktopFlow'
import { MobileFlow } from './flows/MobileFlow'
import { SummaryReporter, type AccountResult } from './flows/SummaryReporter'
import { DISCORD, TIMEOUTS } from './constants'
import { Account } from './interface/Account'
// 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[]
public workers: Workers // Made public for DesktopFlow access
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<string, number> = 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)
}
// Note: Legacy SchedulerManager removed - use OS scheduler (cron/Task Scheduler) instead
// See docs/schedule.md for configuration
}
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<boolean> {
// Check if auto-reset is enabled in config (for scheduled tasks)
if (this.config.jobState?.autoResetOnComplete === true) {
log('main','TASK','Auto-reset enabled (jobState.autoResetOnComplete=true) - resetting and rerunning all accounts', 'log', 'green')
return true
}
// Check environment variable override
const envAutoReset = process.env.REWARDS_AUTO_RESET_JOBSTATE
if (envAutoReset === '1' || envAutoReset?.toLowerCase() === 'true') {
log('main','TASK','Auto-reset enabled (REWARDS_AUTO_RESET_JOBSTATE) - resetting and rerunning all accounts', 'log', 'green')
return true
}
// Detect non-interactive environments more reliably
const isNonInteractive = !process.stdin.isTTY ||
process.env.CI === 'true' ||
process.env.DOCKER === 'true' ||
process.env.SCHEDULED_TASK === 'true'
if (isNonInteractive) {
log('main','TASK','Non-interactive environment detected - keeping job state (set jobState.autoResetOnComplete=true to auto-rerun)', 'warn')
return false
}
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
return new Promise<boolean>((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 {
const passes = this.config.passesPerRun ?? 1
for (let pass = 1; pass <= passes; pass++) {
if (passes > 1) {
log('main', 'MAIN', `Starting pass ${pass}/${passes}`)
}
await this.runTasks(this.accounts)
if (pass < passes) {
log('main', 'MAIN', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
await this.utils.wait(TIMEOUTS.ONE_MINUTE)
}
}
}
}
/** 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 dont 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.method && upd.method !== 'zip') updTargets.push(`Update: ${upd.method}`)
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<number, Account[]>()
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[] }) => {
const passes = this.config.passesPerRun ?? 1
for (let pass = 1; pass <= passes; pass++) {
if (passes > 1) {
log('main', 'MAIN-WORKER', `Starting pass ${pass}/${passes}`)
}
await this.runTasks(chunk)
if (pass < passes) {
log('main', 'MAIN-WORKER', `Completed pass ${pass}/${passes}. Waiting before next pass...`)
await this.utils.wait(TIMEOUTS.ONE_MINUTE)
}
}
})
}
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<void>(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<void> {
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() - delegating to DesktopFlow module')
const desktopFlow = new DesktopFlow(this)
return await desktopFlow.run(account)
}
async Mobile(
account: Account,
retryTracker = new MobileRetryTracker(this.config.searchSettings.retryMobileSearchAmount)
): Promise<{ initialPoints: number; collectedPoints: number }> {
log(true, 'FLOW', 'Mobile() - delegating to MobileFlow module')
const mobileFlow = new MobileFlow(this)
return await mobileFlow.run(account, retryTracker)
}
private async sendConclusion(summaries: AccountSummary[]) {
if (summaries.length === 0) return
// Convert AccountSummary to AccountResult format
const accountResults: AccountResult[] = summaries.map(s => ({
email: s.email,
pointsEarned: s.totalCollected,
runDuration: s.durationMs,
errors: s.errors.length > 0 ? s.errors : undefined
}))
const startTime = new Date(Date.now() - summaries.reduce((sum, s) => sum + s.durationMs, 0))
const endTime = new Date()
// Use SummaryReporter for modern reporting
const reporter = new SummaryReporter(this.config)
const summary = reporter.createSummary(accountResults, startTime, endTime)
// Generate console output and send notifications (webhooks, ntfy, job state)
await reporter.generateReport(summary)
}
// Run optional auto-update script based on configuration flags.
private async runAutoUpdate(): Promise<number> {
const upd = this.config.update
if (!upd) return 0
// Check if updates are enabled
if (upd.enabled === false) {
log('main', 'UPDATE', 'Updates disabled in config (update.enabled = false)')
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[] = []
// Determine update method from config (github-api is default and recommended)
const method = upd.method || 'github-api'
if (method === 'github-api' || method === 'api' || method === 'zip') {
// Use GitHub API method (no Git needed, no conflicts)
args.push('--no-git')
} else {
// Unknown method, default to github-api
log('main', 'UPDATE', `Unknown update method "${method}", using github-api`, 'warn')
args.push('--no-git')
}
// Add Docker flag if enabled
if (upd.docker) args.push('--docker')
// Run update script as a child process and capture exit code
return new Promise<number>((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<void> {
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<void> {
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}`
}
async function main(): Promise<void> {
// 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)
})
}