Files
Microsoft-Rewards-Script/src/index.ts
2026-01-05 16:26:47 +01:00

513 lines
19 KiB
TypeScript

import { AsyncLocalStorage } from 'node:async_hooks'
import cluster, { Worker } from 'cluster'
import type { BrowserContext, Cookie, Page } from 'patchright'
import pkg from '../package.json'
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
import Browser from './browser/Browser'
import BrowserFunc from './browser/BrowserFunc'
import BrowserUtils from './browser/BrowserUtils'
import { IpcLog, Logger } from './logging/Logger'
import Utils from './util/Utils'
import { loadAccounts, loadConfig } from './util/Load'
import { checkNodeVersion } from './util/Validator'
import { Login } from './browser/auth/Login'
import { Workers } from './functions/Workers'
import Activities from './functions/Activities'
import { SearchManager } from './functions/SearchManager'
import type { Account } from './interface/Account'
import AxiosClient from './util/Axios'
import { sendDiscord, flushDiscordQueue } from './logging/Discord'
import { sendNtfy, flushNtfyQueue } from './logging/Ntfy'
import type { DashboardData } from './interface/DashboardData'
import type { AppDashboardData } from './interface/AppDashBoardData'
interface ExecutionContext {
isMobile: boolean
account: Account
}
interface BrowserSession {
context: BrowserContext
fingerprint: BrowserFingerprintWithHeaders
}
interface AccountStats {
email: string
initialPoints: number
finalPoints: number
collectedPoints: number
duration: number
success: boolean
error?: string
}
const executionContext = new AsyncLocalStorage<ExecutionContext>()
export function getCurrentContext(): ExecutionContext {
const context = executionContext.getStore()
if (!context) {
return { isMobile: false, account: {} as any }
}
return context
}
async function flushAllWebhooks(timeoutMs = 5000): Promise<void> {
await Promise.allSettled([flushDiscordQueue(timeoutMs), flushNtfyQueue(timeoutMs)])
}
interface UserData {
userName: string
geoLocale: string
langCode: string
initialPoints: number
currentPoints: number
gainedPoints: number
}
export class MicrosoftRewardsBot {
public logger: Logger
public config
public utils: Utils
public activities: Activities = new Activities(this)
public browser: { func: BrowserFunc; utils: BrowserUtils }
public mainMobilePage!: Page
public mainDesktopPage!: Page
public userData: UserData
public accessToken = ''
public requestToken = ''
public cookies: { mobile: Cookie[]; desktop: Cookie[] }
public fingerprint!: BrowserFingerprintWithHeaders
private pointsCanCollect = 0
private activeWorkers: number
private exitedWorkers: number[]
private browserFactory: Browser = new Browser(this)
private accounts: Account[]
private workers: Workers
private login = new Login(this)
private searchManager: SearchManager
public axios!: AxiosClient
constructor() {
this.userData = {
userName: '',
geoLocale: 'US',
langCode: 'en',
initialPoints: 0,
currentPoints: 0,
gainedPoints: 0
}
this.logger = new Logger(this)
this.accounts = []
this.cookies = { mobile: [], desktop: [] }
this.utils = new Utils()
this.workers = new Workers(this)
this.searchManager = new SearchManager(this)
this.browser = {
func: new BrowserFunc(this),
utils: new BrowserUtils(this)
}
this.config = loadConfig()
this.activeWorkers = this.config.clusters
this.exitedWorkers = []
}
get isMobile(): boolean {
return getCurrentContext().isMobile
}
async initialize(): Promise<void> {
this.accounts = loadAccounts()
}
async run(): Promise<void> {
const totalAccounts = this.accounts.length
const runStartTime = Date.now()
this.logger.info(
'main',
'RUN-START',
`Starting Microsoft Rewards Script | v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}`
)
if (this.config.clusters > 1) {
if (cluster.isPrimary) {
this.runMaster(runStartTime)
} else {
this.runWorker(runStartTime)
}
} else {
await this.runTasks(this.accounts, runStartTime)
}
}
private runMaster(runStartTime: number): void {
void this.logger.info('main', 'CLUSTER-PRIMARY', `Primary process started | PID: ${process.pid}`)
const rawChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
const accountChunks = rawChunks.filter(c => c && c.length > 0)
this.activeWorkers = accountChunks.length
const allAccountStats: AccountStats[] = []
for (const chunk of accountChunks) {
const worker = cluster.fork()
worker.send?.({ chunk, runStartTime })
worker.on('message', (msg: { __ipcLog?: IpcLog; __stats?: AccountStats[] }) => {
if (msg.__stats) {
allAccountStats.push(...msg.__stats)
}
const log = msg.__ipcLog
if (log && typeof log.content === 'string') {
const config = this.config
const webhook = config.webhook
const content = log.content
const level = log.level
if (webhook.discord?.enabled && webhook.discord.url) {
sendDiscord(webhook.discord.url, content, level)
}
if (webhook.ntfy?.enabled && webhook.ntfy.url) {
sendNtfy(webhook.ntfy, content, level)
}
}
})
}
const onWorkerDone = async (label: 'exit' | 'disconnect', worker: Worker, code?: number): Promise<void> => {
const { pid } = worker.process
this.activeWorkers -= 1
if (!pid || this.exitedWorkers.includes(pid)) {
return
} else {
this.exitedWorkers.push(pid)
}
this.logger.warn(
'main',
`CLUSTER-WORKER-${label.toUpperCase()}`,
`Worker ${worker.process?.pid ?? '?'} ${label} | Code: ${code ?? 'n/a'} | Active workers: ${this.activeWorkers}`
)
if (this.activeWorkers <= 0) {
const totalCollectedPoints = allAccountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
const totalInitialPoints = allAccountStats.reduce((sum, s) => sum + s.initialPoints, 0)
const totalFinalPoints = allAccountStats.reduce((sum, s) => sum + s.finalPoints, 0)
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
this.logger.info(
'main',
'RUN-END',
`Completed all accounts | Accounts processed: ${allAccountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
'green'
)
await flushAllWebhooks()
process.exit(code ?? 0)
}
}
cluster.on('exit', (worker, code) => {
void onWorkerDone('exit', worker, code)
})
cluster.on('disconnect', worker => {
void onWorkerDone('disconnect', worker, undefined)
})
}
private runWorker(runStartTimeFromMaster?: number): void {
void this.logger.info('main', 'CLUSTER-WORKER-START', `Worker spawned | PID: ${process.pid}`)
process.on('message', async ({ chunk, runStartTime }: { chunk: Account[]; runStartTime: number }) => {
void this.logger.info(
'main',
'CLUSTER-WORKER-TASK',
`Worker ${process.pid} received ${chunk.length} accounts.`
)
try {
const stats = await this.runTasks(chunk, runStartTime ?? runStartTimeFromMaster ?? Date.now())
if (process.send) {
process.send({ __stats: stats })
}
process.disconnect()
} catch (error) {
this.logger.error(
'main',
'CLUSTER-WORKER-ERROR',
`Worker task crash: ${error instanceof Error ? error.message : String(error)}`
)
await flushAllWebhooks()
process.exit(1)
}
})
}
private async runTasks(accounts: Account[], runStartTime: number): Promise<AccountStats[]> {
const accountStats: AccountStats[] = []
for (const account of accounts) {
const accountStartTime = Date.now()
const accountEmail = account.email
this.userData.userName = this.utils.getEmailUsername(accountEmail)
try {
this.logger.info(
'main',
'ACCOUNT-START',
`Starting account: ${accountEmail} | geoLocale: ${account.geoLocale}`
)
this.axios = new AxiosClient(account.proxy)
const result: { initialPoints: number; collectedPoints: number } | undefined = await this.Main(
account
).catch(error => {
void this.logger.error(
true,
'FLOW',
`Mobile flow failed for ${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
)
return undefined
})
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
if (result) {
const collectedPoints = result.collectedPoints ?? 0
const accountInitialPoints = result.initialPoints ?? 0
const accountFinalPoints = accountInitialPoints + collectedPoints
accountStats.push({
email: accountEmail,
initialPoints: accountInitialPoints,
finalPoints: accountFinalPoints,
collectedPoints: collectedPoints,
duration: parseFloat(durationSeconds),
success: true
})
this.logger.info(
'main',
'ACCOUNT-END',
`Completed account: ${accountEmail} | Total: +${collectedPoints} | Old: ${accountInitialPoints} → New: ${accountFinalPoints} | Duration: ${durationSeconds}s`,
'green'
)
} else {
accountStats.push({
email: accountEmail,
initialPoints: 0,
finalPoints: 0,
collectedPoints: 0,
duration: parseFloat(durationSeconds),
success: false,
error: 'Flow failed'
})
}
} catch (error) {
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
this.logger.error(
'main',
'ACCOUNT-ERROR',
`${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
)
accountStats.push({
email: accountEmail,
initialPoints: 0,
finalPoints: 0,
collectedPoints: 0,
duration: parseFloat(durationSeconds),
success: false,
error: error instanceof Error ? error.message : String(error)
})
}
}
if (this.config.clusters <= 1 && !cluster.isWorker) {
const totalCollectedPoints = accountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
const totalInitialPoints = accountStats.reduce((sum, s) => sum + s.initialPoints, 0)
const totalFinalPoints = accountStats.reduce((sum, s) => sum + s.finalPoints, 0)
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
this.logger.info(
'main',
'RUN-END',
`Completed all accounts | Accounts processed: ${accountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
'green'
)
await flushAllWebhooks()
process.exit()
}
return accountStats
}
async Main(account: Account): Promise<{ initialPoints: number; collectedPoints: number }> {
const accountEmail = account.email
this.logger.info('main', 'FLOW', `Starting session for ${accountEmail}`)
let mobileSession: BrowserSession | null = null
let mobileContextClosed = false
try {
return await executionContext.run({ isMobile: true, account }, async () => {
mobileSession = await this.browserFactory.createBrowser(account)
const initialContext: BrowserContext = mobileSession.context
this.mainMobilePage = await initialContext.newPage()
this.logger.info('main', 'BROWSER', `Mobile Browser started | ${accountEmail}`)
await this.login.login(this.mainMobilePage, account)
try {
this.accessToken = await this.login.getAppAccessToken(this.mainMobilePage, accountEmail)
} catch (error) {
this.logger.error(
'main',
'FLOW',
`Failed to get mobile access token: ${error instanceof Error ? error.message : String(error)}`
)
}
this.cookies.mobile = await initialContext.cookies()
this.fingerprint = mobileSession.fingerprint
const data: DashboardData = await this.browser.func.getDashboardData()
const appData: AppDashboardData = await this.browser.func.getAppDashboardData()
// Set geo
this.userData.geoLocale =
account.geoLocale === 'auto' ? data.userProfile.attributes.country : account.geoLocale.toLowerCase()
if (this.userData.geoLocale.length > 2) {
this.logger.warn(
'main',
'GEO-LOCALE',
`The provided geoLocale is longer than 2 (${this.userData.geoLocale} | auto=${account.geoLocale === 'auto'}), this is likely invalid and can cause errors!`
)
}
this.userData.initialPoints = data.userStatus.availablePoints
this.userData.currentPoints = data.userStatus.availablePoints
const initialPoints = this.userData.initialPoints ?? 0
const browserEarnable = await this.browser.func.getBrowserEarnablePoints()
const appEarnable = await this.browser.func.getAppEarnablePoints()
this.pointsCanCollect = browserEarnable.mobileSearchPoints + (appEarnable?.totalEarnablePoints ?? 0)
this.logger.info(
'main',
'POINTS',
`Earnable today | Mobile: ${this.pointsCanCollect} | Browser: ${
browserEarnable.mobileSearchPoints
} | App: ${appEarnable?.totalEarnablePoints ?? 0} | ${accountEmail} | locale: ${this.userData.geoLocale}`
)
if (this.config.workers.doAppPromotions) await this.workers.doAppPromotions(appData)
if (this.config.workers.doDailySet) await this.workers.doDailySet(data, this.mainMobilePage)
if (this.config.workers.doSpecialPromotions) await this.workers.doSpecialPromotions(data)
if (this.config.workers.doMorePromotions) await this.workers.doMorePromotions(data, this.mainMobilePage)
if (this.config.workers.doDailyCheckIn) await this.activities.doDailyCheckIn()
if (this.config.workers.doReadToEarn) await this.activities.doReadToEarn()
const searchPoints = await this.browser.func.getSearchPoints()
const missingSearchPoints = this.browser.func.missingSearchPoints(searchPoints, true)
this.cookies.mobile = await initialContext.cookies()
const { mobilePoints, desktopPoints } = await this.searchManager.doSearches(
data,
missingSearchPoints,
mobileSession,
account,
accountEmail
)
mobileContextClosed = true
this.userData.gainedPoints = mobilePoints + desktopPoints
const finalPoints = await this.browser.func.getCurrentPoints()
const collectedPoints = finalPoints - initialPoints
this.logger.info(
'main',
'FLOW',
`Collected: +${collectedPoints} | Mobile: +${mobilePoints} | Desktop: +${desktopPoints} | ${accountEmail}`
)
return {
initialPoints,
collectedPoints: collectedPoints || 0
}
})
} finally {
if (mobileSession && !mobileContextClosed) {
try {
await executionContext.run({ isMobile: true, account }, async () => {
await this.browser.func.closeBrowser(mobileSession!.context, accountEmail)
})
} catch {}
}
}
}
}
export { executionContext }
async function main(): Promise<void> {
// Check before doing anything
checkNodeVersion()
const rewardsBot = new MicrosoftRewardsBot()
process.on('beforeExit', () => {
void flushAllWebhooks()
})
process.on('SIGINT', async () => {
rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...')
await flushAllWebhooks()
process.exit(130)
})
process.on('SIGTERM', async () => {
rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...')
await flushAllWebhooks()
process.exit(143)
})
process.on('uncaughtException', async error => {
rewardsBot.logger.error('main', 'UNCAUGHT-EXCEPTION', error)
await flushAllWebhooks()
process.exit(1)
})
process.on('unhandledRejection', async reason => {
rewardsBot.logger.error('main', 'UNHANDLED-REJECTION', reason as Error)
await flushAllWebhooks()
process.exit(1)
})
try {
await rewardsBot.initialize()
await rewardsBot.run()
} catch (error) {
rewardsBot.logger.error('main', 'MAIN-ERROR', error as Error)
}
}
main().catch(async error => {
const tmpBot = new MicrosoftRewardsBot()
tmpBot.logger.error('main', 'MAIN-ERROR', error as Error)
await flushAllWebhooks()
process.exit(1)
})