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

1577 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import cluster from 'cluster'
import type { Worker } from 'cluster'
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
import type { Page } from 'playwright'
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 { DISCORD } from './constants'
import { Login } from './functions/Login'
import { Workers } from './functions/Workers'
import Activities from './functions/Activities'
import { Account } from './interface/Account'
import Axios from './util/Axios'
import fs from 'fs'
import path from 'path'
import { spawn } from 'child_process'
import Humanizer from './util/Humanizer'
import { detectBanReason } from './util/BanDetector'
import { RiskManager, RiskMetrics, RiskEvent } from './util/RiskManager'
import { BanPredictor } from './util/BanPredictor'
import { Analytics } from './util/Analytics'
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
import JobState from './util/JobState'
import { StartupValidator } from './util/StartupValidator'
import { MobileRetryTracker } from './util/MobileRetryTracker'
// 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
public compromisedEmail?: string
// Mutex-like flag to prevent parallel execution when config.parallel is accidentally misconfigured
private isDesktopRunning: boolean = false
private isMobileRunning: boolean = false
private activeWorkers: number
private browserFactory: Browser = new Browser(this)
private accounts: Account[]
private workers: Workers
private login = new Login(this)
private accessToken: string = ''
// Buy mode (manual spending) tracking
private buyMode: { enabled: boolean; email?: string } = { enabled: false }
// Summary collection (per process)
private accountSummaries: AccountSummary[] = []
private runId: string = Math.random().toString(36).slice(2)
private diagCount: number = 0
private bannedTriggered: { email: string; reason: string } | null = null
private globalStandby: { active: boolean; reason?: string } = { active: false }
// Scheduler heartbeat integration
private heartbeatFile?: string
private heartbeatTimer?: NodeJS.Timeout
private riskManager?: RiskManager
private lastRiskMetrics?: RiskMetrics
private riskThresholdTriggered: boolean = false
private banPredictor?: BanPredictor
private analytics?: Analytics
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
})
}
if (this.config.analytics?.enabled) {
this.analytics = new Analytics()
}
if (this.config.riskManagement?.enabled) {
this.riskManager = new RiskManager()
if (this.config.riskManagement.banPrediction) {
this.banPredictor = new BanPredictor(this.riskManager)
}
}
// Buy mode: CLI args take precedence over config
const idx = process.argv.indexOf('-buy')
if (idx >= 0) {
const target = process.argv[idx + 1]
this.buyMode = target && /@/.test(target)
? { enabled: true, email: target }
: { enabled: true }
} else {
// Fallback to config if no CLI flag
const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined
if (buyModeConfig?.enabled === true) {
this.buyMode.enabled = true
}
}
}
public isBuyModeEnabled(): boolean {
return this.buyMode.enabled === true
}
public getBuyModeTarget(): string | undefined {
return this.buyMode.email
}
async initialize() {
this.accounts = loadAccounts()
// Run comprehensive startup validation
const validator = new StartupValidator()
await validator.validate(this.config, this.accounts)
// Always continue - validation is informative, not blocking
// This allows users to proceed even with warnings or minor issues
// Initialize job state
if (this.config.jobState?.enabled !== false) {
this.accountJobState = new JobState(this.config)
}
}
private resetRiskTracking(): void {
if (this.riskManager) {
this.riskManager.reset()
}
this.lastRiskMetrics = undefined
this.riskThresholdTriggered = false
}
private recordRiskEvent(type: RiskEvent['type'], severity: number, context?: string): void {
if (!this.riskManager || this.config.riskManagement?.enabled !== true) return
this.riskManager.recordEvent(type, severity, context)
const metrics = this.riskManager.assessRisk()
this.lastRiskMetrics = metrics
const threshold = this.config.riskManagement?.riskThreshold
if (typeof threshold === 'number' && metrics.score >= threshold && !this.riskThresholdTriggered) {
this.riskThresholdTriggered = true
log('main', 'RISK', `Risk score ${metrics.score} exceeded threshold ${threshold} (level=${metrics.level})`, 'warn', 'yellow')
}
if (this.config.riskManagement?.stopOnCritical && metrics.level === 'critical') {
void this.engageGlobalStandby('risk-critical', this.currentAccountEmail)
}
}
public getRiskDelayMultiplier(): number {
if (!this.config.riskManagement?.enabled || this.config.riskManagement.autoAdjustDelays === false) return 1
return this.lastRiskMetrics?.delayMultiplier ?? 1
}
private trackAnalytics(summary: AccountSummary, riskScore?: number): void {
if (!this.analytics || this.config.analytics?.enabled !== true) return
const today = new Date().toISOString().slice(0, 10)
try {
this.analytics.recordRun({
date: today,
email: summary.email,
pointsEarned: summary.totalCollected,
pointsInitial: summary.initialTotal,
pointsEnd: summary.endTotal,
desktopPoints: summary.desktopCollected,
mobilePoints: summary.mobileCollected,
executionTimeMs: summary.durationMs,
successRate: summary.errors.length ? 0 : 1,
errorsCount: summary.errors.length,
banned: !!summary.banned?.status,
riskScore
})
} catch (e) {
log('main', 'ANALYTICS', `Failed to record analytics for ${summary.email}: ${e instanceof Error ? e.message : e}`, 'warn')
}
}
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'
}
async run() {
this.printBanner()
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
// If scheduler provided a heartbeat file, update it periodically to signal liveness
const hbFile = process.env.SCHEDULER_HEARTBEAT_FILE
if (hbFile) {
try {
const dir = path.dirname(hbFile)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(hbFile, String(Date.now()))
this.heartbeatFile = hbFile
this.heartbeatTimer = setInterval(() => {
try { fs.writeFileSync(hbFile, String(Date.now())) } catch { /* ignore */ }
}, 60_000)
} catch { /* ignore */ }
}
// If buy mode is enabled, run single-account interactive session without automation
if (this.buyMode.enabled) {
const targetInfo = this.buyMode.email ? ` for ${this.buyMode.email}` : ''
log('main', 'BUY-MODE', `Buy mode ENABLED${targetInfo}. We'll open 2 tabs: (1) a monitor tab that auto-refreshes to track points, (2) your browsing tab to redeem/purchase freely.`, 'log', 'green')
log('main', 'BUY-MODE', 'The monitor tab may refresh every ~10s. Use the other tab for your actions; monitoring is passive and non-intrusive.', 'log', 'yellow')
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 email = this.buyMode.email || (this.accounts[0]?.email)
const account = this.accounts.find(a => a.email === email) || this.accounts[0]
if (!account) throw new Error('No account available for buy mode')
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')
}
}
let initial = 0
try {
const data = await this.browser.func.getDashboardData(monitor)
initial = data.userStatus.availablePoints || 0
} catch {/* ignore */}
this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Buy mode is active: monitor tab auto-refreshes; user tab is free for your actions. We'll observe points passively.`)
// Passive watcher: poll points periodically without clicking.
const start = Date.now()
let last = initial
let spent = 0
const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined
const maxMinutes = Math.max(10, buyModeConfig?.maxMinutes ?? 45)
const endAt = start + maxMinutes * 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 { /* ignore */ }
try {
const data = await this.browser.func.getDashboardData(monitor)
const nowPts = data.userStatus.availablePoints || 0
if (nowPts < last) {
// Points decreased -> likely spent
const delta = last - nowPts
spent += delta
last = nowPts
this.log(false, 'BUY-MODE', `Detected spend: -${delta} points (current: ${nowPts})`)
// Immediate spend notice
await sendSpendNotice(delta, nowPts, spent)
} else if (nowPts > last) {
last = nowPts
}
} 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 { /* ignore */ }
}
// Swallow other errors to avoid disrupting the user
}
}
// 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 {/* ignore */}
// Send a final minimal conclusion webhook for this manual session
const summary: AccountSummary = {
email: account.email,
durationMs: Date.now() - start,
desktopCollected: 0,
mobileCollected: 0,
totalCollected: -spent, // negative indicates spend
initialTotal: initial,
endTotal: last,
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 : e}`, 'error')
}
}
private printBanner() {
// Only print once (primary process or single cluster execution)
if (this.config.clusters > 1 && !cluster.isPrimary) return
const banner = `
╔════════════════════════════════════════════════════════╗
║ ║
║ Microsoft Rewards Bot v${this.getVersion().padEnd(5)}
║ Automated Points Collection System ║
║ ║
╚════════════════════════════════════════════════════════╝
`
const buyModeBanner = `
╔════════════════════════════════════════════════════════╗
║ ║
║ Microsoft Rewards Bot - Manual Mode ║
║ Interactive Browsing Session ║
║ ║
╚════════════════════════════════════════════════════════╝
`
const version = this.getVersion()
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner)
console.log('─'.repeat(60))
if (this.buyMode.enabled) {
console.log(` Version ${version} | PID ${process.pid} | Manual Session`)
console.log(` Target: ${this.buyMode.email || 'First account'}`)
} else {
console.log(` Version ${version} | PID ${process.pid} | Workers: ${this.config.clusters}`)
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) {
console.log(` Auto-Update: ${updTargets.join(', ')}`)
}
const sched = this.config.schedule || {}
const schedEnabled = !!sched.enabled
if (!schedEnabled) {
console.log(' Scheduler: Disabled')
} else {
const tz = sched.timeZone || 'UTC'
let formatName = ''
let timeShown = ''
const srec: Record<string, unknown> = sched as unknown as Record<string, unknown>
const useAmPmVal = typeof srec['useAmPm'] === 'boolean' ? (srec['useAmPm'] as boolean) : undefined
const time12Val = typeof srec['time12'] === 'string' ? String(srec['time12']) : undefined
const time24Val = typeof srec['time24'] === 'string' ? String(srec['time24']) : undefined
if (useAmPmVal) {
formatName = '12h'
timeShown = time12Val || sched.time || '9:00 AM'
} else if (useAmPmVal === false) {
formatName = '24h'
timeShown = time24Val || sched.time || '09:00'
} else {
if (time24Val && time24Val.trim()) { formatName = '24h'; timeShown = time24Val }
else if (time12Val && time12Val.trim()) { formatName = '12h'; timeShown = time12Val }
else { formatName = 'auto'; timeShown = sched.time || '09:00' }
}
console.log(` Scheduler: ${timeShown} (${formatName}, ${tz})`)
}
}
console.log('─'.repeat(60) + '\n')
}
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
try {
const cr = this.config.crashRecovery
if (cr?.restartFailedWorker && code !== 0 && worker.id) {
const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
(worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1
log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow')
// CRITICAL FIX: Re-send the original chunk to the new worker
const originalChunk = workerChunkMap.get(worker.id)
const newW = cluster.fork()
if (originalChunk && originalChunk.length > 0 && newW.id) {
// Send the accounts to the new worker
(newW as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk: originalChunk })
// Update mapping with new worker ID
workerChunkMap.set(newW.id, originalChunk)
workerChunkMap.delete(worker.id)
log('main','CRASH-RECOVERY',`Assigned ${originalChunk.length} account(s) to respawned worker`, 'log', 'green')
} else {
log('main','CRASH-RECOVERY','Warning: Could not reassign accounts to respawned worker (chunk not found)', 'warn', 'yellow')
}
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)
})
}
}
} catch (e) {
log('main','CRASH-RECOVERY',`Failed to respawn worker: ${e instanceof Error ? e.message : e}`, 'error')
}
// 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 {/* ignore */}
try {
await this.runAutoUpdate()
} catch {/* ignore */}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
} else {
log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.')
}
})()
}
})
}
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[]) {
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 accountDayKey = this.utils.getFormattedDate()
if (this.shouldSkipAccount(account.email, accountDayKey)) {
log('main','TASK',`Skipping account ${account.email}: already completed on ${accountDayKey} (job-state resume)`, 'warn')
continue
}
// Reset compromised state per account
this.compromisedModeActive = false
this.compromisedReason = undefined
this.compromisedEmail = undefined
this.resetRiskTracking()
// 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'
const formatFullErr = (label: string, e: unknown) => {
const base = shortErr(e)
if (verbose && e instanceof Error) {
return `${label}:${base} :: ${e.stack?.split('\n').slice(0,4).join(' | ')}`
}
return `${label}:${base}`
}
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,
riskScore: 0,
riskLevel: 'safe'
}
this.accountSummaries.push(summary)
this.trackAnalytics(summary, summary.riskScore)
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)
this.recordRiskEvent('error', 6, `desktop:${msg}`)
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)
this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('desktop', e)); return null
})
const mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
this.recordRiskEvent('error', 6, `mobile:${msg}`)
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)
this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); 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')
this.recordRiskEvent('error', 6, `desktop-rejected:${shortErr(desktopResult.reason)}`)
errors.push(formatFullErr('desktop-rejected', desktopResult.reason))
}
// 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')
this.recordRiskEvent('error', 6, `mobile-rejected:${shortErr(mobileResult.reason)}`)
errors.push(formatFullErr('mobile-rejected', mobileResult.reason))
}
} else {
// Sequential execution with safety checks
if (this.isDesktopRunning || this.isMobileRunning) {
log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error')
errors.push('race-condition-detected')
} else {
this.isMobile = false
this.isDesktopRunning = true
const desktopResult = await this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
this.recordRiskEvent('error', 6, `desktop:${msg}`)
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)
this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('desktop', e)); return null
})
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
}
this.isDesktopRunning = false
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
this.isMobileRunning = true
const mobileResult = await this.Mobile(account).catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e)
this.recordRiskEvent('error', 6, `mobile:${msg}`)
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)
this.recordRiskEvent('ban_hint', 9, bd.reason)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
})
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
}
this.isMobileRunning = false
} 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
if (!banned.status) {
this.recordRiskEvent('success', 1, 'account-complete')
}
const riskMetrics = this.lastRiskMetrics
let riskScore = riskMetrics?.score
const riskLevel = riskMetrics?.level
let banPredictionScore: number | undefined
let banLikelihood: string | undefined
if (this.banPredictor && this.config.riskManagement?.banPrediction) {
const prediction = this.banPredictor.predictBanRisk(account.email, runNumber, runNumber)
banPredictionScore = prediction.riskScore
banLikelihood = prediction.likelihood
riskScore = prediction.riskScore
if (prediction.likelihood === 'high' || prediction.likelihood === 'critical') {
log('main', 'RISK', `Ban predictor warning for ${account.email}: likelihood=${prediction.likelihood} score=${prediction.riskScore}`, 'warn', 'yellow')
}
if (banned.status) {
this.banPredictor.recordBan(account.email, runNumber, runNumber)
} else {
this.banPredictor.recordSuccess(account.email, runNumber, runNumber)
}
} else if (banned.status && this.banPredictor) {
this.banPredictor.recordBan(account.email, runNumber, runNumber)
} else if (this.banPredictor && !banned.status) {
this.banPredictor.recordSuccess(account.email, runNumber, runNumber)
}
const summary: AccountSummary = {
email: account.email,
durationMs,
desktopCollected,
mobileCollected,
totalCollected,
initialTotal,
endTotal,
errors,
banned,
riskScore,
riskLevel,
banPredictionScore,
banLikelihood
}
this.accountSummaries.push(summary)
this.trackAnalytics(summary, riskScore)
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}` }
this.recordRiskEvent('ban_hint', 9, `final-ban:${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)
// Cleanup heartbeat timer/file at end of run
if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } }
if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } }
// After conclusion, run optional auto-update
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
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() 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 }
}
this.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(this.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(this.accessToken, data)
}
// Do read to earn
if (this.config.workers.doReadToEarn) {
await this.activities.doReadToEarn(this.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(' | ')}`)
if (summary.riskLevel) {
const scoreLabel = summary.riskScore != null ? `(${summary.riskScore})` : ''
extras.push(`RISK:${summary.riskLevel}${scoreLabel}`)
}
if (summary.banLikelihood) {
const predLabel = summary.banPredictionScore != null ? `(${summary.banPredictionScore})` : ''
extras.push(`PRED:${summary.banLikelihood}${predLabel}`)
}
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 riskSamples = summaries.filter(s => typeof s.riskScore === 'number' && !Number.isNaN(s.riskScore as number))
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)}**`
]
if (riskSamples.length > 0) {
const avgRiskScore = riskSamples.reduce((sum, s) => sum + (s.riskScore ?? 0), 0) / riskSamples.length
const highestRisk = riskSamples.reduce((prev, curr) => ((curr.riskScore ?? 0) > (prev.riskScore ?? 0) ? curr : prev))
globalLines.push(`Risk avg: **${avgRiskScore.toFixed(1)}** • Highest: ${highestRisk.email} (${highestRisk.riskScore ?? 0})`)
}
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')
}
// Cleanup old diagnostics
try {
const days = cfg.diagnostics?.retentionDays
if (typeof days === 'number' && days > 0) {
await this.cleanupOldDiagnostics(days)
}
} catch (e) {
log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn')
}
await this.publishAnalyticsArtifacts().catch(e => {
log('main','ANALYTICS',`Failed analytics post-processing: ${e instanceof Error ? e.message : e}`,'warn')
})
}
/** Reserve one diagnostics slot for this run (caps captures). */
public tryReserveDiagSlot(maxPerRun: number): boolean {
if (this.diagCount >= Math.max(0, maxPerRun || 0)) return false
this.diagCount += 1
return true
}
/** Delete diagnostics folders older than N days under ./reports */
private async cleanupOldDiagnostics(retentionDays: number) {
const base = path.join(process.cwd(), 'reports')
if (!fs.existsSync(base)) return
const entries = fs.readdirSync(base, { withFileTypes: true })
const now = Date.now()
const keepMs = retentionDays * 24 * 60 * 60 * 1000
for (const e of entries) {
if (!e.isDirectory()) continue
const name = e.name // expect YYYY-MM-DD
const parts = name.split('-').map((n: string) => parseInt(n, 10))
if (parts.length !== 3 || parts.some(isNaN)) continue
const [yy, mm, dd] = parts
if (yy === undefined || mm === undefined || dd === undefined) continue
const dirDate = new Date(yy, mm - 1, dd).getTime()
if (isNaN(dirDate)) continue
if (now - dirDate > keepMs) {
const dirPath = path.join(base, name)
try { fs.rmSync(dirPath, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
}
private async publishAnalyticsArtifacts(): Promise<void> {
if (!this.analytics || this.config.analytics?.enabled !== true) return
const retention = this.config.analytics.retentionDays
if (typeof retention === 'number' && retention > 0) {
this.analytics.cleanup(retention)
}
if (this.config.analytics.exportMarkdown || this.config.analytics.webhookSummary) {
const markdown = this.analytics.exportMarkdown(30)
if (this.config.analytics.exportMarkdown) {
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 mdPath = path.join(baseDir, `analytics_${this.runId}.md`)
fs.writeFileSync(mdPath, markdown, 'utf-8')
log('main','ANALYTICS',`Saved analytics summary to ${mdPath}`)
}
if (this.config.analytics.webhookSummary) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(
this.config,
'📈 Performance Report',
['```markdown', markdown, '```'].join('\n'),
undefined,
DISCORD.COLOR_BLUE
)
}
}
}
// Run optional auto-update script based on configuration flags.
private async runAutoUpdate(): Promise<void> {
const upd = this.config.update
if (!upd) return
const scriptRel = upd.scriptPath || 'setup/update/update.mjs'
const scriptAbs = path.join(process.cwd(), scriptRel)
if (!fs.existsSync(scriptAbs)) return
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
// Run update script as a child process - it will handle its own exit
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' })
child.on('close', () => resolve())
child.on('error', () => resolve())
})
}
/** 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 }
riskScore?: number
riskLevel?: string
banPredictionScore?: number
banLikelihood?: 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 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() {
const initialConfig = loadConfig()
const scheduleEnabled = initialConfig?.schedule?.enabled === true
const skipScheduler = process.argv.some((arg: string) => arg === '--no-scheduler' || arg === '--single-run')
|| process.env.REWARDS_FORCE_SINGLE_RUN === '1'
const buyModeRequested = process.argv.includes('-buy')
const invokedByScheduler = !!process.env.SCHEDULER_HEARTBEAT_FILE
if (scheduleEnabled && !skipScheduler && !buyModeRequested && !invokedByScheduler) {
log('main', 'SCHEDULER', 'Schedule enabled → handing off to in-process scheduler. Use --no-scheduler for a single pass.', 'log', 'green')
try {
await import('./scheduler')
return
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Failed to start scheduler inline: ${message}. Continuing with single-run fallback.`, 'warn', 'yellow')
}
}
const rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 }
const config = rewardsBot.config
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) => {
try { rewardsBot['heartbeatTimer'] && clearInterval(rewardsBot['heartbeatTimer']) } catch { /* ignore */ }
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)
})
}