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

1404 lines
64 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'
import type { Page } from 'playwright'
import fs from 'fs'
import path from 'path'
import { spawn } from 'child_process'
import Browser from './browser/Browser'
import BrowserFunc from './browser/BrowserFunc'
import BrowserUtil from './browser/BrowserUtil'
import { log } from './util/Logger'
import { Util } from './util/Utils'
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
import Axios from './util/Axios'
import Humanizer from './util/Humanizer'
import { detectBanReason } from './util/BanDetector'
import { RiskManager, RiskMetrics, RiskEvent } from './util/RiskManager'
import { BanPredictor } from './util/BanPredictor'
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
import JobState from './util/JobState'
import { StartupValidator } from './util/StartupValidator'
import { MobileRetryTracker } from './util/MobileRetryTracker'
import { Login } from './functions/Login'
import { Workers } from './functions/Workers'
import { Activities } from './functions/Activities'
import { Account } from './interface/Account'
import { DISCORD } from './constants'
// Main bot class
export class MicrosoftRewardsBot {
public log: typeof log
public config
public utils: Util
public activities: Activities = new Activities(this)
public browser: {
func: BrowserFunc,
utils: BrowserUtil
}
public humanizer: Humanizer
public isMobile: boolean
public homePage!: Page
public currentAccountEmail?: string
public currentAccountRecoveryEmail?: string
public queryEngine?: QueryDiversityEngine
public compromisedModeActive: boolean = false
public compromisedReason?: string
private activeWorkers: number
private browserFactory: Browser = new Browser(this)
private accounts: Account[]
private workers: Workers
private login = new Login(this)
private buyMode: { enabled: boolean; email?: string } = { enabled: false }
// 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 riskManager?: RiskManager
private lastRiskMetrics?: RiskMetrics
private riskThresholdTriggered: boolean = false
private banPredictor?: BanPredictor
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.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 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 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 (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
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 (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 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 : String(e)}`, 'error')
}
}
private printBanner() {
if (this.config.clusters > 1 && !cluster.isPrimary) return
const version = this.getVersion()
const mode = this.buyMode.enabled ? '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.buyMode.enabled) {
log('main', 'BANNER', `Target: ${this.buyMode.email || 'First account'}`)
} else {
const upd = this.config.update || {}
const updTargets: string[] = []
if (upd.git !== false) updTargets.push('Git')
if (upd.docker) updTargets.push('Docker')
if (updTargets.length > 0) {
log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`)
}
}
}
private getVersion(): string {
try {
const pkgPath = path.join(__dirname, '../', 'package.json')
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(raw)
return pkg.version || '2.51.0'
}
} catch { /* ignore */ }
return '2.51.0'
}
// Return summaries (used when clusters==1)
public getSummaries() {
return this.accountSummaries
}
private runMaster() {
log('main', 'MAIN-PRIMARY', 'Primary process started')
const totalAccounts = this.accounts.length
// Validate accounts exist
if (totalAccounts === 0) {
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn')
process.exit(0)
}
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
const workerCount = Math.min(this.config.clusters, totalAccounts)
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount
// Store worker-to-chunk mapping for crash recovery
const workerChunkMap = new Map<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 {
await this.runAutoUpdate()
} catch (e) {
log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
})()
}
})
}
private runWorker() {
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
// Receive the chunk of accounts from the master
;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => {
await this.runTasks(chunk)
})
}
private async runTasks(accounts: Account[]) {
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.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'
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.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(formatFullError('desktop', e, verbose)); 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(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')
this.recordRiskEvent('error', 6, `desktop-rejected:${shortErr(desktopResult.reason)}`)
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')
this.recordRiskEvent('error', 6, `mobile-rejected:${shortErr(mobileResult.reason)}`)
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)
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(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)
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(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
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.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)
// After conclusion, run optional auto-update
await this.runAutoUpdate().catch((e) => {
log('main', 'UPDATE', `Auto-update failed: ${e instanceof Error ? e.message : String(e)}`, 'warn')
})
}
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 }
}
const accessToken = await this.login.getMobileAccessToken(this.homePage, account.email, account.totp)
await this.browser.func.goHome(this.homePage)
const data = await this.browser.func.getDashboardData()
const initialPoints = data.userStatus.availablePoints || 0
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(accessToken)
const pointsCanCollect = browserEnarablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
log(this.isMobile, 'MAIN-POINTS', `You can earn ${pointsCanCollect} points today (Browser: ${browserEnarablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
if (pointsCanCollect === 0) {
log(this.isMobile, 'MAIN-POINTS', `Breakdown (mobile): browserSearch=${browserEnarablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
log(this.isMobile, 'MAIN-POINTS', 'All mobile earnable buckets are zero. Causes: mobile searches already maxed, daily set finished, or daily rollover not reached yet. You can force execution by setting execution.runOnZeroPoints=true.', 'log', 'yellow')
}
// If runOnZeroPoints is false and 0 points to earn, don't continue
if (!this.config.runOnZeroPoints && pointsCanCollect === 0) {
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
// Close mobile browser
await this.browser.func.closeBrowser(browser, account.email)
return {
initialPoints: initialPoints,
collectedPoints: 0
}
}
// Do daily check in
if (this.config.workers.doDailyCheckIn) {
await this.activities.doDailyCheckIn(accessToken, data)
}
// Do read to earn
if (this.config.workers.doReadToEarn) {
await this.activities.doReadToEarn(accessToken, data)
}
// Do mobile searches
const configuredRetries = Number(this.config.searchSettings.retryMobileSearchAmount ?? 0)
const maxMobileRetries = Number.isFinite(configuredRetries) ? configuredRetries : 0
if (this.config.workers.doMobileSearch) {
// If no mobile searches data found, stop (Does not always exist on new accounts)
if (data.userStatus.counters.mobileSearch) {
// Open a new tab to where the tasks are going to be completed
const workerPage = await browser.newPage()
// Go to homepage on worker page
await this.browser.func.goHome(workerPage)
await this.activities.doSearch(workerPage, data)
// Fetch current search points
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
if (mobileSearchPoints && (mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0) {
const shouldRetry = retryTracker.registerFailure()
if (!shouldRetry) {
const exhaustedAttempts = retryTracker.getAttemptCount()
log(this.isMobile, 'MAIN', `Max retry limit of ${maxMobileRetries} reached after ${exhaustedAttempts} attempt(s). Exiting retry loop`, 'warn')
} else {
const attempt = retryTracker.getAttemptCount()
log(this.isMobile, 'MAIN', `Attempt ${attempt}/${maxMobileRetries}: Unable to complete mobile searches, bad User-Agent? Increase search delay? Retrying...`, 'log', 'yellow')
// Close mobile browser before retrying to release resources
await this.browser.func.closeBrowser(browser, account.email)
// Create a new browser and try again with the same tracker
return await this.Mobile(account, retryTracker)
}
}
} else {
log(this.isMobile, 'MAIN', 'Unable to fetch search points, your account is most likely too "new" for this! Try again later!', 'warn')
}
}
const afterPointAmount = await this.browser.func.getCurrentPoints()
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
// Close mobile browser
await this.browser.func.closeBrowser(browser, account.email)
return {
initialPoints: initialPoints,
collectedPoints: (afterPointAmount - initialPoints) || 0
}
}
private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const cfg = this.config
const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
const ntfyEnabled = !!(cfg.ntfy && cfg.ntfy.enabled)
const webhookEnabled = !!(cfg.webhook && cfg.webhook.enabled)
const totalAccounts = summaries.length
if (totalAccounts === 0) return
let totalCollected = 0
let totalInitial = 0
let totalEnd = 0
let totalDuration = 0
let accountsWithErrors = 0
let successes = 0
// Calculate summary statistics
for (const s of summaries) {
totalCollected += s.totalCollected
totalInitial += s.initialTotal
totalEnd += s.endTotal
totalDuration += s.durationMs
if (s.errors.length) accountsWithErrors++
else successes++
}
const avgDuration = totalDuration / totalAccounts
const avgPointsPerAccount = Math.round(totalCollected / totalAccounts)
// Read package version
let version = 'unknown'
try {
const pkgPath = path.join(process.cwd(), 'package.json')
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(raw)
version = pkg.version || version
}
} catch { /* ignore */ }
const formatNumber = (value: number) => value.toLocaleString()
const formatSigned = (value: number) => `${value >= 0 ? '+' : ''}${formatNumber(value)}`
const padText = (value: string, length: number) => {
if (value.length >= length) {
if (length <= 1) return value.slice(0, length)
return value.length === length ? value : `${value.slice(0, length - 1)}`
}
return value + ' '.repeat(length - value.length)
}
const buildAccountLine = (summary: AccountSummary): string => {
const statusIcon = summary.banned?.status ? '🚫' : (summary.errors.length ? '⚠️' : '✅')
const email = padText(summary.email, 24)
const total = padText(`${formatSigned(summary.totalCollected)} pts`, 13)
const desktop = padText(`D:${formatSigned(summary.desktopCollected)}`, 13)
const mobile = padText(`M:${formatSigned(summary.mobileCollected)}`, 13)
const totals = padText(`${formatNumber(summary.initialTotal)}${formatNumber(summary.endTotal)}`, 21)
const duration = padText(formatDuration(summary.durationMs), 10)
const extras: string[] = []
if (summary.banned?.status) extras.push(`BAN:${summary.banned.reason || 'detected'}`)
if (summary.errors.length) extras.push(`ERR:${summary.errors.slice(0, 1).join(' | ')}`)
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')
}
}
// 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 formatFullError(label: string, e: unknown, verbose: boolean): string {
const base = shortErr(e)
if (verbose && e instanceof Error && e.stack) {
return `${label}:${base} :: ${e.stack.split('\n').slice(0, 4).join(' | ')}`
}
return `${label}:${base}`
}
function formatDuration(ms: number): string {
if (!ms || ms < 1000) return `${ms}ms`
const sec = Math.floor(ms / 1000)
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
const parts: string[] = []
if (h) parts.push(`${h}h`)
if (m) parts.push(`${m}m`)
if (s) parts.push(`${s}s`)
return parts.join(' ') || `${ms}ms`
}
async function main() {
// Check for dashboard mode flag (standalone dashboard)
if (process.argv.includes('-dashboard')) {
const { startDashboardServer } = await import('./dashboard/server')
const { dashboardState } = await import('./dashboard/state')
log('main', 'DASHBOARD', 'Starting standalone dashboard server...')
// Load and initialize accounts
try {
const accounts = loadAccounts()
dashboardState.initializeAccounts(accounts.map(a => a.email))
log('main', 'DASHBOARD', `Initialized ${accounts.length} accounts in dashboard`)
} catch (error) {
log('main', 'DASHBOARD', 'Could not load accounts: ' + (error instanceof Error ? error.message : String(error)), 'warn')
}
startDashboardServer()
return
}
const rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 }
const config = rewardsBot.config
// Auto-start dashboard if enabled in config
if (config.dashboard?.enabled) {
const { DashboardServer } = await import('./dashboard/server')
const { dashboardState } = await import('./dashboard/state')
const port = config.dashboard.port || 3000
const host = config.dashboard.host || '127.0.0.1'
// Override env vars with config values
process.env.DASHBOARD_PORT = String(port)
process.env.DASHBOARD_HOST = host
// Initialize dashboard with accounts
const accounts = loadAccounts()
dashboardState.initializeAccounts(accounts.map(a => a.email))
const dashboardServer = new DashboardServer()
dashboardServer.start()
log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`)
}
const attachHandlers = () => {
process.on('unhandledRejection', (reason: unknown) => {
log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')
gracefulExit(1)
})
process.on('uncaughtException', (err: Error) => {
log('main','FATAL','UncaughtException: ' + err.message, 'error')
gracefulExit(1)
})
process.on('SIGTERM', () => gracefulExit(0))
process.on('SIGINT', () => gracefulExit(0))
}
const gracefulExit = (code: number) => {
if (config?.crashRecovery?.autoRestart && code !== 0) {
const max = config.crashRecovery.maxRestarts ?? 2
if (crashState.restarts < max) {
const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1)
log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow')
setTimeout(() => {
crashState.restarts++
bootstrap()
}, backoff)
return
}
}
process.exit(code)
}
const bootstrap = async () => {
try {
await rewardsBot.initialize()
await rewardsBot.run()
} catch (e) {
log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error')
gracefulExit(1)
}
}
attachHandlers()
await bootstrap()
}
// Start the bots
if (require.main === module) {
main().catch(error => {
log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error')
process.exit(1)
})
}