mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 09:16:16 +00:00
1366 lines
61 KiB
TypeScript
1366 lines
61 KiB
TypeScript
import cluster from 'cluster'
|
||
import type { Worker } from 'cluster'
|
||
import type { Page } from 'playwright'
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
import { spawn } from 'child_process'
|
||
import { createInterface } from 'readline'
|
||
|
||
import Browser from './browser/Browser'
|
||
import BrowserFunc from './browser/BrowserFunc'
|
||
import BrowserUtil from './browser/BrowserUtil'
|
||
|
||
import { log } from './util/Logger'
|
||
import { Util } from './util/Utils'
|
||
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
|
||
import Axios from './util/Axios'
|
||
import Humanizer from './util/Humanizer'
|
||
import { detectBanReason } from './util/BanDetector'
|
||
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
|
||
import JobState from './util/JobState'
|
||
import { StartupValidator } from './util/StartupValidator'
|
||
import { MobileRetryTracker } from './util/MobileRetryTracker'
|
||
import { SchedulerManager } from './util/SchedulerManager'
|
||
import { BuyModeSelector, BuyModeMonitor } from './util/BuyMode'
|
||
|
||
import { Login } from './functions/Login'
|
||
import { Workers } from './functions/Workers'
|
||
import { Activities } from './functions/Activities'
|
||
|
||
import { Account } from './interface/Account'
|
||
import { DISCORD } from './constants'
|
||
|
||
|
||
// Main bot class
|
||
export class MicrosoftRewardsBot {
|
||
public log: typeof log
|
||
public config
|
||
public utils: Util
|
||
public activities: Activities = new Activities(this)
|
||
public browser: {
|
||
func: BrowserFunc,
|
||
utils: BrowserUtil
|
||
}
|
||
public humanizer: Humanizer
|
||
public isMobile: boolean
|
||
public homePage!: Page
|
||
public currentAccountEmail?: string
|
||
public currentAccountRecoveryEmail?: string
|
||
public queryEngine?: QueryDiversityEngine
|
||
public compromisedModeActive: boolean = false
|
||
public compromisedReason?: string
|
||
|
||
private activeWorkers: number
|
||
private browserFactory: Browser = new Browser(this)
|
||
private accounts: Account[]
|
||
private workers: Workers
|
||
private login = new Login(this)
|
||
private buyModeEnabled: boolean = false
|
||
private buyModeArgument?: string
|
||
|
||
// Summary collection (per process)
|
||
private accountSummaries: AccountSummary[] = []
|
||
private runId: string = Math.random().toString(36).slice(2)
|
||
private bannedTriggered: { email: string; reason: string } | null = null
|
||
private globalStandby: { active: boolean; reason?: string } = { active: false }
|
||
private accountJobState?: JobState
|
||
private accountRunCounts: Map<string, number> = new Map()
|
||
|
||
public axios!: Axios
|
||
|
||
constructor(isMobile: boolean) {
|
||
this.isMobile = isMobile
|
||
this.log = log
|
||
|
||
this.accounts = []
|
||
this.utils = new Util()
|
||
this.config = loadConfig()
|
||
if (this.config.jobState?.enabled !== false) {
|
||
this.accountJobState = new JobState(this.config)
|
||
}
|
||
this.browser = {
|
||
func: new BrowserFunc(this),
|
||
utils: new BrowserUtil(this)
|
||
}
|
||
this.workers = new Workers(this)
|
||
this.humanizer = new Humanizer(this.utils, this.config.humanization)
|
||
this.activeWorkers = this.config.clusters
|
||
|
||
if (this.config.queryDiversity?.enabled) {
|
||
this.queryEngine = new QueryDiversityEngine({
|
||
sources: this.config.queryDiversity.sources,
|
||
maxQueriesPerSource: this.config.queryDiversity.maxQueriesPerSource,
|
||
cacheMinutes: this.config.queryDiversity.cacheMinutes
|
||
})
|
||
}
|
||
|
||
// Buy mode: CLI args take precedence over config
|
||
const idx = process.argv.indexOf('-buy')
|
||
if (idx >= 0) {
|
||
this.buyModeEnabled = true
|
||
this.buyModeArgument = process.argv[idx + 1]
|
||
} else {
|
||
// Fallback to config if no CLI flag
|
||
const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined
|
||
if (buyModeConfig?.enabled === true) {
|
||
this.buyModeEnabled = true
|
||
}
|
||
}
|
||
}
|
||
|
||
public isBuyModeEnabled(): boolean {
|
||
return this.buyModeEnabled === true
|
||
}
|
||
|
||
public getBuyModeTarget(): string | undefined {
|
||
return this.buyModeArgument
|
||
}
|
||
|
||
async initialize() {
|
||
this.accounts = loadAccounts()
|
||
|
||
// Run comprehensive startup validation
|
||
const validator = new StartupValidator()
|
||
try {
|
||
await validator.validate(this.config, this.accounts)
|
||
} catch (error) {
|
||
// Critical validation errors prevent startup
|
||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||
log('main', 'VALIDATION', `Fatal validation error: ${errorMsg}`, 'error')
|
||
throw error // Re-throw to stop execution
|
||
}
|
||
|
||
// Validation passed - continue with initialization
|
||
|
||
// Initialize job state
|
||
if (this.config.jobState?.enabled !== false) {
|
||
this.accountJobState = new JobState(this.config)
|
||
}
|
||
|
||
// Setup or remove automatic scheduler based on config
|
||
const scheduler = new SchedulerManager(this.config)
|
||
await scheduler.setup()
|
||
}
|
||
|
||
private shouldSkipAccount(email: string, dayKey: string): boolean {
|
||
if (!this.accountJobState) return false
|
||
if (this.config.jobState?.skipCompletedAccounts === false) return false
|
||
if ((this.config.passesPerRun ?? 1) > 1) return false
|
||
if (this.isAccountSkipOverride()) return false
|
||
return this.accountJobState.isAccountComplete(email, dayKey)
|
||
}
|
||
|
||
private persistAccountCompletion(email: string, dayKey: string, summary: AccountSummary): void {
|
||
if (!this.accountJobState) return
|
||
if (this.config.jobState?.skipCompletedAccounts === false) return
|
||
if ((this.config.passesPerRun ?? 1) > 1) return
|
||
if (this.isAccountSkipOverride()) return
|
||
this.accountJobState.markAccountComplete(email, dayKey, {
|
||
runId: this.runId,
|
||
totalCollected: summary.totalCollected,
|
||
banned: summary.banned?.status === true,
|
||
errors: summary.errors.length
|
||
})
|
||
}
|
||
|
||
private isAccountSkipOverride(): boolean {
|
||
const value = process.env.REWARDS_DISABLE_ACCOUNT_SKIP
|
||
if (!value) return false
|
||
const lower = value.toLowerCase()
|
||
return value === '1' || lower === 'true' || lower === 'yes'
|
||
}
|
||
|
||
private async promptResetJobState(): Promise<boolean> {
|
||
// Skip prompt in non-interactive environments (Docker, CI, scheduled tasks)
|
||
if (!process.stdin.isTTY) {
|
||
log('main','TASK','Non-interactive environment detected - keeping job state', 'warn')
|
||
return false
|
||
}
|
||
|
||
const rl = createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
})
|
||
|
||
return new Promise<boolean>((resolve) => {
|
||
rl.question('\n⚠️ Reset job state and run all accounts again? (y/N): ', (answer) => {
|
||
rl.close()
|
||
const trimmed = answer.trim().toLowerCase()
|
||
resolve(trimmed === 'y' || trimmed === 'yes')
|
||
})
|
||
})
|
||
}
|
||
|
||
private resetAllJobStates(): void {
|
||
if (!this.accountJobState) return
|
||
|
||
const jobStateDir = this.accountJobState.getJobStateDir()
|
||
if (!fs.existsSync(jobStateDir)) return
|
||
|
||
const files = fs.readdirSync(jobStateDir).filter(f => f.endsWith('.json'))
|
||
for (const file of files) {
|
||
try {
|
||
fs.unlinkSync(path.join(jobStateDir, file))
|
||
} catch {
|
||
// Ignore errors
|
||
}
|
||
}
|
||
}
|
||
|
||
async run() {
|
||
this.printBanner()
|
||
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||
|
||
// If buy mode is enabled, run single-account interactive session without automation
|
||
if (this.buyModeEnabled) {
|
||
await this.runBuyMode()
|
||
return
|
||
}
|
||
|
||
// Only cluster when there's more than 1 cluster demanded
|
||
if (this.config.clusters > 1) {
|
||
if (cluster.isPrimary) {
|
||
this.runMaster()
|
||
} else {
|
||
this.runWorker()
|
||
}
|
||
} else {
|
||
await this.runTasks(this.accounts)
|
||
}
|
||
}
|
||
|
||
/** Manual spending session: login, then leave control to user while we passively monitor points. */
|
||
private async runBuyMode() {
|
||
try {
|
||
await this.initialize()
|
||
|
||
const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined
|
||
const maxMinutes = buyModeConfig?.maxMinutes ?? 45
|
||
|
||
const selector = new BuyModeSelector(this.accounts)
|
||
const selection = await selector.selectAccount(this.buyModeArgument, maxMinutes)
|
||
|
||
if (!selection) {
|
||
log('main', 'BUY-MODE', 'Buy mode cancelled: no account selected', 'warn')
|
||
return
|
||
}
|
||
|
||
const { account, maxMinutes: sessionMaxMinutes } = selection
|
||
|
||
log('main', 'BUY-MODE', `Buy mode ENABLED for ${account.email}. Opening 2 tabs: (1) monitor tab (auto-refresh), (2) your browsing tab`, 'log', 'green')
|
||
log('main', 'BUY-MODE', `Session duration: ${sessionMaxMinutes} minutes. Monitor tab refreshes every ~10s. Use the other tab for your actions.`, 'log', 'yellow')
|
||
|
||
this.isMobile = false
|
||
this.axios = new Axios(account.proxy)
|
||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
||
// Open the monitor tab FIRST so auto-refresh happens out of the way
|
||
let monitor = await browser.newPage()
|
||
await this.login.login(monitor, account.email, account.password, account.totp)
|
||
await this.browser.func.goHome(monitor)
|
||
this.log(false, 'BUY-MODE', 'Opened MONITOR tab (auto-refreshes to track points).', 'log', 'yellow')
|
||
|
||
// Then open the user free-browsing tab SECOND so users don’t see the refreshes
|
||
const page = await browser.newPage()
|
||
await this.browser.func.goHome(page)
|
||
this.log(false, 'BUY-MODE', 'Opened USER tab (use this one to redeem/purchase freely).', 'log', 'green')
|
||
|
||
// Helper to recreate monitor tab if the user closes it
|
||
const recreateMonitor = async () => {
|
||
try { if (!monitor.isClosed()) await monitor.close() } catch { /* ignore */ }
|
||
monitor = await browser.newPage()
|
||
await this.browser.func.goHome(monitor)
|
||
}
|
||
|
||
// Helper to send an immediate spend notice via webhooks/NTFY
|
||
const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => {
|
||
try {
|
||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||
await ConclusionWebhook(
|
||
this.config,
|
||
'💳 Spend Detected',
|
||
`**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`,
|
||
undefined,
|
||
0xFFAA00
|
||
)
|
||
} catch (e) {
|
||
this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn')
|
||
}
|
||
}
|
||
|
||
// Get initial points
|
||
let initial = 0
|
||
try {
|
||
const data = await this.browser.func.getDashboardData(monitor)
|
||
initial = data.userStatus.availablePoints || 0
|
||
} catch {/* ignore */}
|
||
|
||
const pointMonitor = new BuyModeMonitor(initial)
|
||
|
||
this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Starting passive point monitoring (session: ${sessionMaxMinutes} min)`)
|
||
|
||
// Passive watcher: poll points periodically without clicking.
|
||
const start = Date.now()
|
||
const endAt = start + sessionMaxMinutes * 60 * 1000
|
||
|
||
while (Date.now() < endAt) {
|
||
await this.utils.wait(10000)
|
||
|
||
// If monitor tab was closed by user, recreate it quietly
|
||
try {
|
||
if (monitor.isClosed()) {
|
||
this.log(false, 'BUY-MODE', 'Monitor tab was closed; reopening in background...', 'warn')
|
||
await recreateMonitor()
|
||
}
|
||
} catch (e) {
|
||
this.log(false, 'BUY-MODE', `Failed to check/recreate monitor tab: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||
}
|
||
|
||
try {
|
||
const data = await this.browser.func.getDashboardData(monitor)
|
||
const nowPts = data.userStatus.availablePoints || 0
|
||
|
||
const spendInfo = pointMonitor.checkSpending(nowPts)
|
||
if (spendInfo) {
|
||
this.log(false, 'BUY-MODE', `Detected spend: -${spendInfo.spent} points (current: ${spendInfo.current})`)
|
||
await sendSpendNotice(spendInfo.spent, spendInfo.current, spendInfo.total)
|
||
}
|
||
} catch (err) {
|
||
// If we lost the page context, recreate the monitor tab and continue
|
||
const msg = err instanceof Error ? err.message : String(err)
|
||
if (/Target closed|page has been closed|browser has been closed/i.test(msg)) {
|
||
this.log(false, 'BUY-MODE', 'Monitor page closed or lost; recreating...', 'warn')
|
||
try {
|
||
await recreateMonitor()
|
||
} catch (e) {
|
||
this.log(false, 'BUY-MODE', `Failed to recreate monitor: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||
}
|
||
} else {
|
||
this.log(false, 'BUY-MODE', `Dashboard check error: ${msg}`, 'warn')
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save cookies and close monitor; keep main page open for user until they close it themselves
|
||
try {
|
||
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
|
||
} catch (e) {
|
||
log(false, 'BUY-MODE', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||
}
|
||
try {
|
||
if (!monitor.isClosed()) await monitor.close()
|
||
} catch (e) {
|
||
log(false, 'BUY-MODE', `Failed to close monitor tab: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||
}
|
||
|
||
// Send a final minimal conclusion webhook for this manual session
|
||
const monitorSummary = pointMonitor.getSummary()
|
||
const summary: AccountSummary = {
|
||
email: account.email,
|
||
durationMs: monitorSummary.duration,
|
||
desktopCollected: 0,
|
||
mobileCollected: 0,
|
||
totalCollected: -monitorSummary.spent, // negative indicates spend
|
||
initialTotal: monitorSummary.initial,
|
||
endTotal: monitorSummary.current,
|
||
errors: [],
|
||
banned: { status: false, reason: '' }
|
||
}
|
||
await this.sendConclusion([summary])
|
||
|
||
this.log(false, 'BUY-MODE', 'Buy mode session finished (monitoring period ended). You can close the browser when done.')
|
||
} catch (e) {
|
||
this.log(false, 'BUY-MODE', `Error in buy mode: ${e instanceof Error ? e.message : String(e)}`, 'error')
|
||
}
|
||
}
|
||
|
||
private printBanner() {
|
||
if (this.config.clusters > 1 && !cluster.isPrimary) return
|
||
|
||
const version = this.getVersion()
|
||
const mode = this.buyModeEnabled ? 'Manual Mode' : 'Automated Mode'
|
||
|
||
log('main', 'BANNER', `Microsoft Rewards Bot v${version} - ${mode}`)
|
||
log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`)
|
||
|
||
if (this.buyModeEnabled) {
|
||
log('main', 'BANNER', `Target: ${this.buyModeArgument || 'Interactive selection'}`)
|
||
} else {
|
||
const upd = this.config.update || {}
|
||
const updTargets: string[] = []
|
||
if (upd.git !== false) updTargets.push('Git')
|
||
if (upd.docker) updTargets.push('Docker')
|
||
if (updTargets.length > 0) {
|
||
log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
private getVersion(): string {
|
||
try {
|
||
const pkgPath = path.join(__dirname, '../', 'package.json')
|
||
if (fs.existsSync(pkgPath)) {
|
||
const raw = fs.readFileSync(pkgPath, 'utf-8')
|
||
const pkg = JSON.parse(raw)
|
||
return pkg.version || '2.51.0'
|
||
}
|
||
} catch { /* ignore */ }
|
||
return '2.51.0'
|
||
}
|
||
|
||
// Return summaries (used when clusters==1)
|
||
public getSummaries() {
|
||
return this.accountSummaries
|
||
}
|
||
|
||
private runMaster() {
|
||
log('main', 'MAIN-PRIMARY', 'Primary process started')
|
||
|
||
const totalAccounts = this.accounts.length
|
||
|
||
// Validate accounts exist
|
||
if (totalAccounts === 0) {
|
||
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn')
|
||
process.exit(0)
|
||
}
|
||
|
||
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
|
||
const workerCount = Math.min(this.config.clusters, totalAccounts)
|
||
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
|
||
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
|
||
this.activeWorkers = workerCount
|
||
|
||
// Store worker-to-chunk mapping for crash recovery
|
||
const workerChunkMap = new Map<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[]) {
|
||
// Check if all accounts are already completed and prompt user
|
||
const accountDayKey = this.utils.getFormattedDate()
|
||
const allCompleted = accounts.every(acc => this.shouldSkipAccount(acc.email, accountDayKey))
|
||
|
||
if (allCompleted && accounts.length > 0) {
|
||
log('main','TASK',`All accounts already completed on ${accountDayKey}`, 'warn', 'yellow')
|
||
const shouldReset = await this.promptResetJobState()
|
||
if (shouldReset) {
|
||
this.resetAllJobStates()
|
||
log('main','TASK','Job state reset - proceeding with all accounts', 'log', 'green')
|
||
} else {
|
||
log('main','TASK','Keeping existing job state - exiting', 'log')
|
||
return
|
||
}
|
||
}
|
||
|
||
for (const account of accounts) {
|
||
// If a global standby is active due to security/banned, stop processing further accounts
|
||
if (this.globalStandby.active) {
|
||
log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow')
|
||
break
|
||
}
|
||
// Optional global stop after first ban
|
||
if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) {
|
||
log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn')
|
||
break
|
||
}
|
||
const currentDayKey = this.utils.getFormattedDate()
|
||
if (this.shouldSkipAccount(account.email, currentDayKey)) {
|
||
log('main','TASK',`Skipping account ${account.email}: already completed on ${currentDayKey} (job-state resume)`, 'warn')
|
||
continue
|
||
}
|
||
// Reset compromised state per account
|
||
this.compromisedModeActive = false
|
||
this.compromisedReason = undefined
|
||
|
||
// If humanization allowed windows are configured, wait until within a window
|
||
try {
|
||
const windows: string[] | undefined = this.config?.humanization?.allowedWindows
|
||
if (Array.isArray(windows) && windows.length > 0) {
|
||
const waitMs = this.computeWaitForAllowedWindow(windows)
|
||
if (waitMs > 0) {
|
||
log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn')
|
||
await new Promise<void>(r => setTimeout(r, waitMs))
|
||
}
|
||
}
|
||
} catch {/* ignore */}
|
||
this.currentAccountEmail = account.email
|
||
this.currentAccountRecoveryEmail = (typeof account.recoveryEmail === 'string' && account.recoveryEmail.trim() !== '')
|
||
? account.recoveryEmail.trim()
|
||
: undefined
|
||
const runNumber = (this.accountRunCounts.get(account.email) ?? 0) + 1
|
||
this.accountRunCounts.set(account.email, runNumber)
|
||
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||
|
||
const accountStart = Date.now()
|
||
let desktopInitial = 0
|
||
let mobileInitial = 0
|
||
let desktopCollected = 0
|
||
let mobileCollected = 0
|
||
const errors: string[] = []
|
||
const banned = { status: false, reason: '' }
|
||
|
||
this.axios = new Axios(account.proxy)
|
||
const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1'
|
||
|
||
if (this.config.dryRun) {
|
||
log('main', 'DRY-RUN', `Dry run: skipping automation for ${account.email}`)
|
||
const summary: AccountSummary = {
|
||
email: account.email,
|
||
durationMs: 0,
|
||
desktopCollected: 0,
|
||
mobileCollected: 0,
|
||
totalCollected: 0,
|
||
initialTotal: 0,
|
||
endTotal: 0,
|
||
errors: [],
|
||
banned
|
||
}
|
||
this.accountSummaries.push(summary)
|
||
this.persistAccountCompletion(account.email, accountDayKey, summary)
|
||
continue
|
||
}
|
||
|
||
if (this.config.parallel) {
|
||
const mobileInstance = new MicrosoftRewardsBot(true)
|
||
mobileInstance.axios = this.axios
|
||
// Run both and capture results with detailed logging
|
||
const desktopPromise = this.Desktop(account).catch((e: unknown) => {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
|
||
const bd = detectBanReason(e)
|
||
if (bd.status) {
|
||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||
}
|
||
errors.push(formatFullError('desktop', e, verbose)); return null
|
||
})
|
||
const mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
|
||
const bd = detectBanReason(e)
|
||
if (bd.status) {
|
||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||
}
|
||
errors.push(formatFullError('mobile', e, verbose)); return null
|
||
})
|
||
const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
|
||
|
||
// Handle desktop result
|
||
if (desktopResult.status === 'fulfilled' && desktopResult.value) {
|
||
desktopInitial = desktopResult.value.initialPoints
|
||
desktopCollected = desktopResult.value.collectedPoints
|
||
} else if (desktopResult.status === 'rejected') {
|
||
log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error')
|
||
errors.push(formatFullError('desktop-rejected', desktopResult.reason, verbose))
|
||
}
|
||
|
||
// Handle mobile result
|
||
if (mobileResult.status === 'fulfilled' && mobileResult.value) {
|
||
mobileInitial = mobileResult.value.initialPoints
|
||
mobileCollected = mobileResult.value.collectedPoints
|
||
} else if (mobileResult.status === 'rejected') {
|
||
log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error')
|
||
errors.push(formatFullError('mobile-rejected', mobileResult.reason, verbose))
|
||
}
|
||
} else {
|
||
// Sequential execution with safety checks
|
||
this.isMobile = false
|
||
const desktopResult = await this.Desktop(account).catch(e => {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
|
||
const bd = detectBanReason(e)
|
||
if (bd.status) {
|
||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||
}
|
||
errors.push(formatFullError('desktop', e, verbose)); return null
|
||
})
|
||
if (desktopResult) {
|
||
desktopInitial = desktopResult.initialPoints
|
||
desktopCollected = desktopResult.collectedPoints
|
||
}
|
||
|
||
if (!banned.status && !this.compromisedModeActive) {
|
||
this.isMobile = true
|
||
const mobileResult = await this.Mobile(account).catch((e: unknown) => {
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
|
||
const bd = detectBanReason(e)
|
||
if (bd.status) {
|
||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||
}
|
||
errors.push(formatFullError('mobile', e, verbose)); return null
|
||
})
|
||
if (mobileResult) {
|
||
mobileInitial = mobileResult.initialPoints
|
||
mobileCollected = mobileResult.collectedPoints
|
||
}
|
||
} else {
|
||
const why = banned.status ? 'banned status' : 'compromised status'
|
||
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
|
||
}
|
||
}
|
||
|
||
const accountEnd = Date.now()
|
||
const durationMs = accountEnd - accountStart
|
||
const totalCollected = desktopCollected + mobileCollected
|
||
|
||
// Sequential mode: desktop runs first, mobile starts with desktop's end points
|
||
// Parallel mode: both start from same baseline, take minimum to avoid double-count
|
||
const initialTotal = this.config.parallel
|
||
? Math.min(desktopInitial || Infinity, mobileInitial || Infinity)
|
||
: (desktopInitial || mobileInitial || 0)
|
||
|
||
const endTotal = initialTotal + totalCollected
|
||
|
||
const summary: AccountSummary = {
|
||
email: account.email,
|
||
durationMs,
|
||
desktopCollected,
|
||
mobileCollected,
|
||
totalCollected,
|
||
initialTotal,
|
||
endTotal,
|
||
errors,
|
||
banned
|
||
}
|
||
|
||
this.accountSummaries.push(summary)
|
||
this.persistAccountCompletion(account.email, accountDayKey, summary)
|
||
|
||
if (banned.status) {
|
||
this.bannedTriggered = { email: account.email, reason: banned.reason }
|
||
// Enter global standby: do not proceed to next accounts
|
||
this.globalStandby = { active: true, reason: `banned:${banned.reason}` }
|
||
await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`)
|
||
}
|
||
|
||
await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
|
||
}
|
||
|
||
await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
|
||
// Extra diagnostic summary when verbose
|
||
if (process.env.DEBUG_REWARDS_VERBOSE === '1') {
|
||
for (const summary of this.accountSummaries) {
|
||
log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`)
|
||
}
|
||
}
|
||
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
|
||
if (this.compromisedModeActive || this.globalStandby.active) {
|
||
log('main','SECURITY','Security alert active. Process kept alive for manual review. Press CTRL+C to exit when done.','warn','yellow')
|
||
// Periodic heartbeat with cleanup on exit
|
||
const standbyInterval = setInterval(() => {
|
||
log('main','SECURITY','Standby mode active: sessions kept open for review...','warn','yellow')
|
||
}, 5 * 60 * 1000)
|
||
|
||
// Cleanup on process exit
|
||
process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) })
|
||
process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) })
|
||
return
|
||
}
|
||
// If in worker mode (clusters>1) send summaries to primary
|
||
if (this.config.clusters > 1 && !cluster.isPrimary) {
|
||
if (process.send) {
|
||
process.send({ type: 'summary', data: this.accountSummaries })
|
||
}
|
||
} else {
|
||
// Single process mode -> build and send conclusion directly
|
||
await this.sendConclusion(this.accountSummaries)
|
||
// After conclusion, run optional auto-update
|
||
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(' | ')}`)
|
||
const tail = extras.length ? ` | ${extras.join(' • ')}` : ''
|
||
return `${statusIcon} ${email} ${total} ${desktop}${mobile} ${totals} ${duration}${tail}`
|
||
}
|
||
const chunkLines = (lines: string[], maxLen = 900): string[][] => {
|
||
const chunks: string[][] = []
|
||
let current: string[] = []
|
||
let currentLen = 0
|
||
for (const line of lines) {
|
||
const nextLen = line.length + 1
|
||
if (current.length > 0 && currentLen + nextLen > maxLen) {
|
||
chunks.push(current)
|
||
current = []
|
||
currentLen = 0
|
||
}
|
||
current.push(line)
|
||
currentLen += nextLen
|
||
}
|
||
if (current.length) chunks.push(current)
|
||
return chunks
|
||
}
|
||
|
||
const accountLines = summaries.map(buildAccountLine)
|
||
const accountChunks = chunkLines(accountLines)
|
||
|
||
const globalLines = [
|
||
`Total points: **${formatNumber(totalInitial)}** → **${formatNumber(totalEnd)}** (${formatSigned(totalCollected)} pts)`,
|
||
`Accounts: ✅ ${successes}${accountsWithErrors > 0 ? ` • ⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`,
|
||
`Average per account: **${formatSigned(avgPointsPerAccount)} pts** • **${formatDuration(avgDuration)}**`,
|
||
`Runtime: **${formatDuration(totalDuration)}**`
|
||
]
|
||
|
||
const globalStatsValue = globalLines.join('\n')
|
||
|
||
const fields: { name: string; value: string; inline?: boolean }[] = [
|
||
{ name: '📊 Summary', value: globalStatsValue, inline: false }
|
||
]
|
||
|
||
if (accountChunks.length === 0) {
|
||
fields.push({ name: '📋 Accounts', value: '_No results recorded_', inline: false })
|
||
} else {
|
||
accountChunks.forEach((chunk, index) => {
|
||
const name = accountChunks.length === 1 ? '📋 Accounts' : `📋 Accounts (${index + 1}/${accountChunks.length})`
|
||
fields.push({ name, value: ['```', ...chunk, '```'].join('\n'), inline: false })
|
||
})
|
||
}
|
||
|
||
// Send webhook
|
||
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
|
||
await ConclusionWebhook(
|
||
cfg,
|
||
'✅ Daily Run Complete',
|
||
`**v${version}** • ${this.runId}`,
|
||
fields,
|
||
accountsWithErrors > 0 ? DISCORD.COLOR_ORANGE : DISCORD.COLOR_GREEN
|
||
)
|
||
}
|
||
|
||
// Write local JSON report
|
||
try {
|
||
const fs = await import('fs')
|
||
const path = await import('path')
|
||
const now = new Date()
|
||
const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||
const baseDir = path.join(process.cwd(), 'reports', day)
|
||
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
|
||
const file = path.join(baseDir, `summary_${this.runId}.json`)
|
||
const payload = {
|
||
runId: this.runId,
|
||
timestamp: now.toISOString(),
|
||
totals: { totalCollected, totalInitial, totalEnd, totalDuration, totalAccounts, accountsWithErrors },
|
||
perAccount: summaries
|
||
}
|
||
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8')
|
||
log('main','REPORT',`Saved report to ${file}`)
|
||
} catch (e) {
|
||
log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
|
||
}
|
||
|
||
}
|
||
|
||
// Run optional auto-update script based on configuration flags.
|
||
private async runAutoUpdate(): Promise<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 }
|
||
}
|
||
|
||
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)
|
||
})
|
||
} |