diff --git a/README.md b/README.md index 856bca4..2a3ff19 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r | proxy.proxyBingTerms | Enable or disable proxying the request via set proxy | `true` (will be proxied) | | webhook.enabled | Enable or disable your set webhook | `false` | | webhook.url | Your Discord webhook URL | `null` | +| conclusionWebhook.enabled | Enable or disable the final summary dedicated webhook | `false` | +| conclusionWebhook.url | Discord webhook URL used ONLY for the end summary | `null` | ## Features ## - [x] Multi-Account Support @@ -90,6 +92,7 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r - [x] Passwordless Support - [x] Headless Support - [x] Discord Webhook Support + - [x] Final Summary Webhook (dedicated optional) - [x] Desktop Searches - [x] Configurable Tasks - [x] Microsoft Edge Searches diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 2b309d9..f60bf05 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -92,9 +92,32 @@ export default class BrowserFunc { this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page') await this.goHome(this.bot.homePage) } - - // Reload the page to get new data - await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' }) + let lastError: any = null + for (let attempt = 1; attempt <= 2; attempt++) { + try { + // Reload the page to get new data + await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' }) + lastError = null + break + } catch (re) { + lastError = re + const msg = (re instanceof Error ? re.message : String(re)) + this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn') + // If page/context closed => bail early after first retry + if (msg.includes('has been closed')) { + if (attempt === 1) { + this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn') + try { + await this.goHome(this.bot.homePage) + } catch {/* ignore */} + } else { + break + } + } + if (attempt === 2 && lastError) throw lastError + await this.bot.utils.wait(1000) + } + } const scriptContent = await this.bot.homePage.evaluate(() => { const scripts = Array.from(document.querySelectorAll('script')) @@ -108,7 +131,7 @@ export default class BrowserFunc { } // Extract the dashboard object from the script content - const dashboardData = await this.bot.homePage.evaluate(scriptContent => { + const dashboardData = await this.bot.homePage.evaluate((scriptContent: string) => { // Extract the dashboard object using regex const regex = /var dashboard = (\{.*?\});/s const match = regex.exec(scriptContent) @@ -272,7 +295,7 @@ export default class BrowserFunc { const html = await page.content() const $ = load(html) - const scriptContent = $('script').filter((index, element) => { + const scriptContent = $('script').filter((index: number, element: any) => { return $(element).text().includes('_w.rewardsQuizRenderInfo') }).text() @@ -332,7 +355,7 @@ export default class BrowserFunc { const html = await page.content() const $ = load(html) - const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId)) + const element = $('.offer-cta').toArray().find((x: any) => x.attribs.href?.includes(activity.offerId)) if (element) { selector = `a[href*="${element.attribs.href}"]` } diff --git a/src/browser/BrowserUtil.ts b/src/browser/BrowserUtil.ts index 5d1f070..7952e23 100644 --- a/src/browser/BrowserUtil.ts +++ b/src/browser/BrowserUtil.ts @@ -40,6 +40,24 @@ export default class BrowserUtil { // Silent fail } } + + // Handle blocking Bing privacy overlay intercepting clicks (#bnp_overlay_wrapper) + try { + const overlay = await page.locator('#bnp_overlay_wrapper').first() + if (await overlay.isVisible({ timeout: 500 }).catch(()=>false)) { + // Try common dismiss buttons inside overlay + const rejectBtn = await page.locator('#bnp_btn_reject, button[aria-label*="Reject" i]').first() + const acceptBtn = await page.locator('#bnp_btn_accept').first() + if (await rejectBtn.isVisible().catch(()=>false)) { + await rejectBtn.click({ timeout: 500 }).catch(()=>{}) + this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject') + } else if (await acceptBtn.isVisible().catch(()=>false)) { + await acceptBtn.click({ timeout: 500 }).catch(()=>{}) + this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Accept (fallback)') + } + await page.waitForTimeout(300) + } + } catch { /* ignore */ } } async getLatestTab(page: Page): Promise { diff --git a/src/config.json b/src/config.json index 5be6f24..daf334f 100644 --- a/src/config.json +++ b/src/config.json @@ -43,5 +43,9 @@ "webhook": { "enabled": false, "url": "" + }, + "conclusionWebhook": { + "enabled": false, + "url": "" } } \ No newline at end of file diff --git a/src/functions/Login.ts b/src/functions/Login.ts index ee364ad..2795b18 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -1,4 +1,4 @@ -import { Page } from 'rebrowser-playwright' +import type { Page } from 'playwright' import readline from 'readline' import * as crypto from 'crypto' import { AxiosRequestConfig } from 'axios' @@ -10,8 +10,9 @@ import { OAuth } from '../interface/OAuth' const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout + // Use as any to avoid strict typing issues with our minimal process shim + input: (process as any).stdin, + output: (process as any).stdout }) export class Login { @@ -21,6 +22,8 @@ export class Login { private redirectUrl: string = 'https://login.live.com/oauth20_desktop.srf' private tokenUrl: string = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token' private scope: string = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL' + // Flag to prevent spamming passkey logs after first handling + private passkeyHandled: boolean = false constructor(bot: MicrosoftRewardsBot) { this.bot = bot @@ -35,7 +38,7 @@ export class Login { await page.goto('https://rewards.bing.com/signin') // Disable FIDO support in login request - await page.route('**/GetCredentialType.srf*', (route) => { + await page.route('**/GetCredentialType.srf*', (route: any) => { const body = JSON.parse(route.request().postData() || '{}') body.isFidoSupported = false route.continue({ postData: JSON.stringify(body) }) @@ -265,7 +268,7 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'SMS 2FA code required. Waiting for user input...') const code = await new Promise((resolve) => { - rl.question('Enter 2FA code:\n', (input) => { + rl.question('Enter 2FA code:\n', (input: string) => { rl.close() resolve(input) }) @@ -287,21 +290,32 @@ export class Login { authorizeUrl.searchParams.append('access_type', 'offline_access') authorizeUrl.searchParams.append('login_hint', email) + // Disable FIDO for OAuth flow as well (reduces passkey prompts resurfacing) + await page.route('**/GetCredentialType.srf*', (route: any) => { + const body = JSON.parse(route.request().postData() || '{}') + body.isFidoSupported = false + route.continue({ postData: JSON.stringify(body) }) + }).catch(()=>{}) + await page.goto(authorizeUrl.href) let currentUrl = new URL(page.url()) let code: string + const authStart = Date.now() this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Waiting for authorization...') // eslint-disable-next-line no-constant-condition while (true) { + // Attempt to dismiss passkey/passkey-like screens quickly (non-blocking) + await this.tryDismissPasskeyPrompt(page) if (currentUrl.hostname === 'login.live.com' && currentUrl.pathname === '/oauth20_desktop.srf') { code = currentUrl.searchParams.get('code')! break } currentUrl = new URL(page.url()) - await this.bot.utils.wait(5000) + // Shorter wait to react faster to passkey prompt + await this.bot.utils.wait(1000) } const body = new URLSearchParams() @@ -322,7 +336,8 @@ export class Login { const tokenResponse = await this.bot.axios.request(tokenRequest) const tokenData: OAuth = await tokenResponse.data - this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Successfully authorized') + const authDuration = Date.now() - authStart + this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Successfully authorized in ${Math.round(authDuration/1000)}s`) return tokenData.access_token } @@ -346,70 +361,141 @@ export class Login { this.bot.log(this.bot.isMobile, 'LOGIN', 'Successfully logged into the rewards portal') } + private lastNoPromptLog: number = 0 + private noPromptIterations: number = 0 private async dismissLoginMessages(page: Page) { - // Passkey / Windows Hello prompt ("Sign in faster"), click "Skip for now" - // Primary heuristics: presence of biometric video OR title mentions passkey/sign in faster - const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 2000 }).catch(() => null) - let handledPasskey = false + let didSomething = false + + // PASSKEY / Windows Hello / Sign in faster + const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 1000 }).catch(() => null) if (passkeyVideo) { - const skipButton = await page.$('[data-testid="secondaryButton"]') + const skipButton = await page.$('button[data-testid="secondaryButton"]') if (skipButton) { - await skipButton.click() - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Use Passkey" modal via data-testid=secondaryButton') - await page.waitForTimeout(500) - handledPasskey = true + await skipButton.click().catch(()=>{}) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog detected (video heuristic) -> clicked "Skip for now"') + } + this.passkeyHandled = true + await page.waitForTimeout(300) + didSomething = true } } - - if (!handledPasskey) { - // Fallback heuristics: title text or presence of primary+secondary buttons typical of the passkey screen - const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 1000 }).catch(() => null) + if (!didSomething) { + const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 800 }).catch(() => null) const titleText = (titleEl ? (await titleEl.textContent()) : '')?.trim() || '' - const looksLikePasskeyTitle = /sign in faster|passkey/i.test(titleText) - - const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 1000 }).catch(() => null) - const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 1000 }).catch(() => null) - - if (looksLikePasskeyTitle && secondaryBtn) { - await secondaryBtn.click() - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by title + secondaryButton') - await page.waitForTimeout(500) - handledPasskey = true - } else if (secondaryBtn && primaryBtn) { - // If both buttons are visible (Next + Skip for now), prefer the secondary (Skip for now) - await secondaryBtn.click() - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by button pair heuristic') - await page.waitForTimeout(500) - handledPasskey = true - } else if (!handledPasskey) { - // Last-resort fallbacks by text and close icon - const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first() - if (await skipByText.isVisible().catch(() => false)) { - await skipByText.click() - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen via text fallback') - await page.waitForTimeout(500) - handledPasskey = true - } else { - const closeBtn = await page.$('#close-button') - if (closeBtn) { - await closeBtn.click().catch(() => { }) - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Attempted to close Passkey screen via close button') - await page.waitForTimeout(500) + const looksLikePasskey = /sign in faster|passkey|fingerprint|face|pin/i.test(titleText) + const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null) + const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 500 }).catch(() => null) + if (looksLikePasskey && secondaryBtn) { + await secondaryBtn.click().catch(()=>{}) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey dialog detected (title: "${titleText}") -> clicked secondary`) + } + this.passkeyHandled = true + await page.waitForTimeout(300) + didSomething = true + } else if (!didSomething && secondaryBtn && primaryBtn) { + const secText = (await secondaryBtn.textContent() || '').trim() + if (/skip for now/i.test(secText)) { + await secondaryBtn.click().catch(()=>{}) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (pair heuristic) -> clicked secondary (Skip for now)') } + this.passkeyHandled = true + await page.waitForTimeout(300) + didSomething = true + } + } + if (!didSomething) { + const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first() + if (await skipByText.isVisible().catch(()=>false)) { + await skipByText.click().catch(()=>{}) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (text fallback) -> clicked "Skip for now"') + } + this.passkeyHandled = true + await page.waitForTimeout(300) + didSomething = true + } + } + if (!didSomething) { + const closeBtn = await page.$('#close-button') + if (closeBtn) { + await closeBtn.click().catch(()=>{}) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Attempted close button on potential passkey modal') + } + this.passkeyHandled = true + await page.waitForTimeout(300) } } } - // Use Keep me signed in - if (await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 2000 }).catch(() => null)) { - const yesButton = await page.$('[data-testid="primaryButton"]') + // KMSI (Keep me signed in) prompt + const kmsi = await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 800 }).catch(()=>null) + if (kmsi) { + const yesButton = await page.$('button[data-testid="primaryButton"]') if (yesButton) { - await yesButton.click() - this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Keep me signed in" modal') - await page.waitForTimeout(500) + await yesButton.click().catch(()=>{}) + this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'KMSI dialog detected -> accepted (Yes)') + await page.waitForTimeout(300) + didSomething = true } } + if (!didSomething) { + this.noPromptIterations++ + const now = Date.now() + if (this.noPromptIterations === 1 || (now - this.lastNoPromptLog) > 10000) { + this.lastNoPromptLog = now + this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`) + // Reset counter if it grows large to keep number meaningful + if (this.noPromptIterations > 50) this.noPromptIterations = 0 + } + } else { + // Reset counters after an interaction + this.noPromptIterations = 0 + } + } + + /** Lightweight passkey prompt dismissal used in mobile OAuth loop */ + private async tryDismissPasskeyPrompt(page: Page) { + try { + // Fast existence checks with very small timeouts to avoid slowing the loop + const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 500 }).catch(() => null) + const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null) + // Direct text locator fallback (sometimes data-testid changes) + const textSkip = secondaryBtn ? null : await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first().isVisible().catch(()=>false) + if (secondaryBtn) { + // Heuristic: if title indicates passkey or both primary/secondary exist with typical text + let shouldClick = false + let titleText = '' + if (titleEl) { + titleText = (await titleEl.textContent() || '').trim() + if (/sign in faster|passkey|fingerprint|face|pin/i.test(titleText)) { + shouldClick = true + } + } + if (!shouldClick && textSkip) { + shouldClick = true + } + if (!shouldClick) { + // Fallback text probe on the secondary button itself + const btnText = (await secondaryBtn.textContent() || '').trim() + if (/skip for now/i.test(btnText)) { + shouldClick = true + } + } + if (shouldClick) { + await secondaryBtn.click().catch(() => { }) + if (!this.passkeyHandled) { + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey prompt (loop) -> clicked skip${titleText ? ` (title: ${titleText})` : ''}`) + } + this.passkeyHandled = true + await this.bot.utils.wait(500) + } + } + } catch { /* ignore minor errors */ } } private async checkBingLogin(page: Page): Promise { diff --git a/src/functions/activities/Quiz.ts b/src/functions/activities/Quiz.ts index e4a27e5..e0a15a7 100644 --- a/src/functions/activities/Quiz.ts +++ b/src/functions/activities/Quiz.ts @@ -30,7 +30,7 @@ export class Quiz extends Workers { for (let i = 0; i < quizData.numberOfOptions; i++) { const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 }) - const answerAttribute = await answerSelector?.evaluate(el => el.getAttribute('iscorrectoption')) + const answerAttribute = await answerSelector?.evaluate((el: any) => el.getAttribute('iscorrectoption')) if (answerAttribute && answerAttribute.toLowerCase() === 'true') { answers.push(`#rqAnswerOption${i}`) @@ -60,7 +60,7 @@ export class Quiz extends Workers { for (let i = 0; i < quizData.numberOfOptions; i++) { const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 }) - const dataOption = await answerSelector?.evaluate(el => el.getAttribute('data-option')) + const dataOption = await answerSelector?.evaluate((el: any) => el.getAttribute('data-option')) if (dataOption === correctOption) { // Click the answer on page diff --git a/src/functions/activities/Search.ts b/src/functions/activities/Search.ts index 5aebb63..c96c2e5 100644 --- a/src/functions/activities/Search.ts +++ b/src/functions/activities/Search.ts @@ -295,7 +295,7 @@ export class Search extends Workers { const totalHeight = await page.evaluate(() => document.body.scrollHeight) const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight)) - await page.evaluate((scrollPos) => { + await page.evaluate((scrollPos: number) => { window.scrollTo(0, scrollPos) }, randomScrollPosition) diff --git a/src/functions/activities/SearchOnBing.ts b/src/functions/activities/SearchOnBing.ts index 6670c16..1a8e91c 100644 --- a/src/functions/activities/SearchOnBing.ts +++ b/src/functions/activities/SearchOnBing.ts @@ -1,4 +1,4 @@ -import { Page } from 'rebrowser-playwright' +import type { Page } from 'playwright' import * as fs from 'fs' import path from 'path' @@ -21,7 +21,7 @@ export class SearchOnBing extends Workers { const searchBar = '#sb_form_q' await page.waitForSelector(searchBar, { state: 'visible', timeout: 10000 }) - await page.click(searchBar) + await this.safeClick(page, searchBar) await this.bot.utils.wait(500) await page.keyboard.type(query) await page.keyboard.press('Enter') @@ -36,6 +36,22 @@ export class SearchOnBing extends Workers { } } + private async safeClick(page: Page, selector: string) { + try { + await page.click(selector, { timeout: 5000 }) + } catch (e: any) { + const msg = (e?.message || '') + if (/Timeout.*click/i.test(msg) || /intercepts pointer events/i.test(msg)) { + // Try to dismiss overlays then retry once + await this.bot.browser.utils.tryDismissAllMessages(page) + await this.bot.utils.wait(500) + await page.click(selector, { timeout: 5000 }) + } else { + throw e + } + } + } + private async getSearchQuery(title: string): Promise { interface Queries { title: string; diff --git a/src/index.ts b/src/index.ts index 0a15130..2574092 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import cluster from 'cluster' -import { Page } from 'rebrowser-playwright' +// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright +import type { Page } from 'playwright' import Browser from './browser/Browser' import BrowserFunc from './browser/BrowserFunc' @@ -15,6 +16,8 @@ import Activities from './functions/Activities' import { Account } from './interface/Account' import Axios from './util/Axios' +import fs from 'fs' +import path from 'path' // Main bot class @@ -41,6 +44,9 @@ export class MicrosoftRewardsBot { private login = new Login(this) private accessToken: string = '' + // Summary collection (per process) + private accountSummaries: AccountSummary[] = [] + //@ts-expect-error Will be initialized later public axios: Axios @@ -65,6 +71,7 @@ export class MicrosoftRewardsBot { } async run() { + this.printBanner() log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`) // Only cluster when there's more than 1 cluster demanded @@ -79,6 +86,37 @@ export class MicrosoftRewardsBot { } } + private printBanner() { + // Only print once (primary process or single cluster execution) + if (this.config.clusters > 1 && !cluster.isPrimary) return + try { + const pkgPath = path.join(__dirname, '../', 'package.json') + let version = 'unknown' + if (fs.existsSync(pkgPath)) { + const raw = fs.readFileSync(pkgPath, 'utf-8') + const pkg = JSON.parse(raw) + version = pkg.version || version + } + const banner = [ + ' __ __ _____ _____ _ ', + ' | \/ |/ ____| | __ \\ | | ', + ' | \ / | (___ ______| |__) |_____ ____ _ _ __ __| |___ ', + ' | |\/| |\\___ \\______| _ // _ \\ \\ /\\ / / _` | \'__/ _` / __|', + ' | | | |____) | | | \\ \\ __/ \\ V V / (_| | | | (_| \\__ \\', + ' |_| |_|_____/ |_| \\_\\___| \\_/\\_/ \\__,_|_| \\__,_|___/', + '', + ` Version: v${version}`, + '' + ].join('\n') + console.log(banner) + } catch { /* ignore banner errors */ } + } + + // Return summaries (used when clusters==1) + public getSummaries() { + return this.accountSummaries + } + private runMaster() { log('main', 'MAIN-PRIMARY', 'Primary process started') @@ -87,18 +125,27 @@ export class MicrosoftRewardsBot { for (let i = 0; i < accountChunks.length; i++) { const worker = cluster.fork() const chunk = accountChunks[i] - worker.send({ chunk }) + ;(worker as any).send?.({ chunk }) + // Collect summaries from workers + worker.on('message', (msg: any) => { + if (msg && msg.type === 'summary' && Array.isArray(msg.data)) { + this.accountSummaries.push(...msg.data) + } + }) } - cluster.on('exit', (worker, code) => { + cluster.on('exit', (worker: any, code: number) => { this.activeWorkers -= 1 log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn') // Check if all workers have exited if (this.activeWorkers === 0) { - log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') - process.exit(0) + // All workers done -> send conclusion (if enabled) then exit + this.sendConclusion(this.accountSummaries).finally(() => { + log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') + process.exit(0) + }) } }) } @@ -106,7 +153,7 @@ export class MicrosoftRewardsBot { private runWorker() { log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`) // Receive the chunk of accounts from the master - process.on('message', async ({ chunk }) => { + ;(process as any).on('message', async ({ chunk }: { chunk: Account[] }) => { await this.runTasks(chunk) }) } @@ -115,29 +162,74 @@ export class MicrosoftRewardsBot { for (const account of accounts) { 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[] = [] + this.axios = new Axios(account.proxy) if (this.config.parallel) { - await Promise.all([ - this.Desktop(account), - (() => { - const mobileInstance = new MicrosoftRewardsBot(true) - mobileInstance.axios = this.axios - - return mobileInstance.Mobile(account) - })() + const mobileInstance = new MicrosoftRewardsBot(true) + mobileInstance.axios = this.axios + // Run both and capture results + const [desktopResult, mobileResult] = await Promise.all([ + this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null }), + mobileInstance.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null }) ]) + if (desktopResult) { + desktopInitial = desktopResult.initialPoints + desktopCollected = desktopResult.collectedPoints + } + if (mobileResult) { + mobileInitial = mobileResult.initialPoints + mobileCollected = mobileResult.collectedPoints + } } else { this.isMobile = false - await this.Desktop(account) + const desktopResult = await this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null }) + if (desktopResult) { + desktopInitial = desktopResult.initialPoints + desktopCollected = desktopResult.collectedPoints + } this.isMobile = true - await this.Mobile(account) + const mobileResult = await this.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null }) + if (mobileResult) { + mobileInitial = mobileResult.initialPoints + mobileCollected = mobileResult.collectedPoints + } } + const accountEnd = Date.now() + const durationMs = accountEnd - accountStart + const totalCollected = desktopCollected + mobileCollected + const initialTotal = (desktopInitial || 0) + (mobileInitial || 0) + this.accountSummaries.push({ + email: account.email, + durationMs, + desktopCollected, + mobileCollected, + totalCollected, + initialTotal, + endTotal: initialTotal + totalCollected, + errors + }) + log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green') } log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green') + // 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) + } process.exit() } @@ -155,7 +247,8 @@ export class MicrosoftRewardsBot { const data = await this.browser.func.getDashboardData() - this.pointsInitial = data.userStatus.availablePoints + this.pointsInitial = data.userStatus.availablePoints + const initial = this.pointsInitial log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`) @@ -206,9 +299,14 @@ export class MicrosoftRewardsBot { // 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 + return { + initialPoints: initial, + collectedPoints: (after - initial) || 0 + } } // Mobile @@ -224,7 +322,8 @@ export class MicrosoftRewardsBot { await this.browser.func.goHome(this.homePage) - const data = await this.browser.func.getDashboardData() + const data = await this.browser.func.getDashboardData() + const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0 const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints() const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken) @@ -239,9 +338,11 @@ export class MicrosoftRewardsBot { // Close mobile browser await this.browser.func.closeBrowser(browser, account.email) - return + return { + initialPoints: initialPoints, + collectedPoints: 0 + } } - // Do daily check in if (this.config.workers.doDailyCheckIn) { await this.activities.doDailyCheckIn(this.accessToken, data) @@ -292,13 +393,113 @@ export class MicrosoftRewardsBot { const afterPointAmount = await this.browser.func.getCurrentPoints() - log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - this.pointsInitial} points today`) + log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`) // Close mobile browser await this.browser.func.closeBrowser(browser, account.email) - return + return { + initialPoints: initialPoints, + collectedPoints: (afterPointAmount - initialPoints) || 0 + } } + private async sendConclusion(summaries: AccountSummary[]) { + const { ConclusionWebhook } = await import('./util/ConclusionWebhook') + const cfg = this.config + if (!cfg.conclusionWebhook || !cfg.conclusionWebhook.enabled) return + + const totalAccounts = summaries.length + if (totalAccounts === 0) return + + let totalCollected = 0 + let totalInitial = 0 + let totalEnd = 0 + let totalDuration = 0 + let accountsWithErrors = 0 + + const accountFields: any[] = [] + for (const s of summaries) { + totalCollected += s.totalCollected + totalInitial += s.initialTotal + totalEnd += s.endTotal + totalDuration += s.durationMs + if (s.errors.length) accountsWithErrors++ + + const statusEmoji = s.errors.length ? '⚠️' : '✅' + const diff = s.totalCollected + const duration = formatDuration(s.durationMs) + const valueLines: string[] = [ + `Points: ${s.initialTotal} → ${s.endTotal} ( +${diff} )`, + `Breakdown: 🖥️ ${s.desktopCollected} | 📱 ${s.mobileCollected}`, + `Duration: ⏱️ ${duration}` + ] + if (s.errors.length) { + valueLines.push(`Errors: ${s.errors.slice(0,2).join(' | ')}`) + } + accountFields.push({ + name: `${statusEmoji} ${s.email}`.substring(0, 256), + value: valueLines.join('\n').substring(0, 1024), + inline: false + }) + } + + const avgDuration = totalDuration / totalAccounts + const embed = { + title: '🎯 Microsoft Rewards Summary', + description: `Processed **${totalAccounts}** account(s)${accountsWithErrors ? ` • ${accountsWithErrors} with issues` : ''}`, + color: accountsWithErrors ? 0xFFAA00 : 0x32CD32, + fields: [ + { + name: 'Global Totals', + value: [ + `Total Points: ${totalInitial} → ${totalEnd} ( +${totalCollected} )`, + `Average Duration: ${formatDuration(avgDuration)}`, + `Cumulative Runtime: ${formatDuration(totalDuration)}` + ].join('\n') + }, + ...accountFields + ].slice(0, 25), // Discord max 25 fields + timestamp: new Date().toISOString(), + footer: { + text: 'Script conclusion webhook' + } + } + + // Fallback plain text (rare) & embed send + const fallback = `Microsoft Rewards Summary\nAccounts: ${totalAccounts}\nTotal: ${totalInitial} -> ${totalEnd} (+${totalCollected})\nRuntime: ${formatDuration(totalDuration)}` + await ConclusionWebhook(cfg, fallback, { embeds: [embed] }) + } +} + +interface AccountSummary { + email: string + durationMs: number + desktopCollected: number + mobileCollected: number + totalCollected: number + initialTotal: number + endTotal: number + errors: string[] +} + +function shortErr(e: any): string { + if (!e) return 'unknown' + if (e instanceof Error) return e.message.substring(0, 120) + const s = String(e) + return s.substring(0, 120) +} + +function formatDuration(ms: number): string { + if (!ms || ms < 1000) return `${ms}ms` + const sec = Math.floor(ms / 1000) + const h = Math.floor(sec / 3600) + const m = Math.floor((sec % 3600) / 60) + const s = sec % 60 + const parts: string[] = [] + if (h) parts.push(`${h}h`) + if (m) parts.push(`${m}m`) + if (s) parts.push(`${s}s`) + return parts.join(' ') || `${ms}ms` } async function main() { diff --git a/src/interface/Config.ts b/src/interface/Config.ts index f34a5f7..0194f7c 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -14,6 +14,7 @@ export interface Config { webhookLogExcludeFunc: string[]; proxy: ConfigProxy; webhook: ConfigWebhook; + conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary } export interface ConfigSaveFingerprint { diff --git a/src/types/node-shim.d.ts b/src/types/node-shim.d.ts new file mode 100644 index 0000000..ad6e863 --- /dev/null +++ b/src/types/node-shim.d.ts @@ -0,0 +1,78 @@ +// Fallback in case @types/node not installed yet; ensures Process/stubs to reduce red squiggles. +// Prefer installing @types/node for full types. + +interface ProcessEnv { [key: string]: string | undefined } + +interface Process { + pid: number + exit(code?: number): never + send?(message: any): void + on(event: string, listener: (...args: any[]) => void): any + stdin: { on(event: string, listener: (...args: any[]) => void): any } + stdout: { write(chunk: any): boolean } + env: ProcessEnv +} + +declare var process: Process + +// Minimal axios module declaration +declare module 'axios' { + export interface AxiosRequestConfig { [key: string]: any } + export interface AxiosResponse { data: T } + export interface AxiosInstance { + defaults: any + request(config: AxiosRequestConfig): Promise> + } + export interface AxiosStatic { + (config: AxiosRequestConfig): Promise + request(config: AxiosRequestConfig): Promise> + create(config?: AxiosRequestConfig): AxiosInstance + } + const axios: AxiosStatic + export default axios +} + +// Minimal readline +declare module 'readline' { + export interface Interface { question(query: string, cb: (answer: string)=>void): void; close(): void } + export function createInterface(opts: any): Interface + export default {} as any +} + +// Minimal crypto +declare module 'crypto' { + export function randomBytes(size: number): { toString(encoding: string): string } +} + +// Minimal os module +declare module 'os' { + export function platform(): string +} + +// Minimal cheerio subset +declare module 'cheerio' { + export interface CheerioAPI { + (selector: any): any + load(html: string): CheerioAPI + text(): string + } + export function load(html: string): CheerioAPI +} + +declare module 'cluster' { + import { EventEmitter } from 'events' + interface WorkerLike extends EventEmitter { + id: number + process: { pid: number } + send?(message: any): void + on(event: 'message', listener: (msg: any) => void): any + } + interface Cluster extends EventEmitter { + isPrimary: boolean + fork(env?: NodeJS.ProcessEnv): WorkerLike + workers?: Record + on(event: 'exit', listener: (worker: WorkerLike, code: number) => void): any + } + const cluster: Cluster + export default cluster +} diff --git a/src/types/rebrowser-playwright.d.ts b/src/types/rebrowser-playwright.d.ts new file mode 100644 index 0000000..2c2e375 --- /dev/null +++ b/src/types/rebrowser-playwright.d.ts @@ -0,0 +1,57 @@ +// Minimal module declaration to silence TS complaints if upstream types not found. +// You should replace with actual types if the package provides them. + +// Basic playwright stubs (only what we currently need). Replace with real @types if available. +declare module 'playwright' { + export interface Cookie { name: string; value: string; domain?: string; path?: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: 'Lax'|'Strict'|'None' } + export interface BrowserContext { + newPage(): Promise + setDefaultTimeout(timeout: number): void + addCookies(cookies: Cookie[]): Promise + cookies(): Promise + pages(): Page[] + close(): Promise + } + export interface Browser { + newPage(): Promise + context(): BrowserContext + close(): Promise + pages?(): Page[] + } + export interface Keyboard { + type(text: string): Promise + press(key: string): Promise + down(key: string): Promise + up(key: string): Promise + } + export interface Locator { + first(): Locator + click(opts?: any): Promise + isVisible(opts?: any): Promise + nth(index: number): Locator + } + export interface Page { + goto(url: string, opts?: any): Promise + waitForLoadState(state?: string, opts?: any): Promise + waitForSelector(selector: string, opts?: any): Promise + fill(selector: string, value: string): Promise + keyboard: Keyboard + click(selector: string, opts?: any): Promise + close(): Promise + url(): string + route(match: string, handler: any): Promise + locator(selector: string): Locator + $: (selector: string) => Promise + context(): BrowserContext + reload(opts?: any): Promise + evaluate(pageFunction: any, arg?: any): Promise + content(): Promise + waitForTimeout(timeout: number): Promise + } + export interface ChromiumType { launch(opts?: any): Promise } + export const chromium: ChromiumType +} + +declare module 'rebrowser-playwright' { + export * from 'playwright' +} diff --git a/src/types/shims-node.d.ts b/src/types/shims-node.d.ts new file mode 100644 index 0000000..207b12c --- /dev/null +++ b/src/types/shims-node.d.ts @@ -0,0 +1,25 @@ +// Minimal shims to silence TypeScript errors in environments without @types/node +// If possible, install @types/node instead for full typing. + +declare const __dirname: string + +declare namespace NodeJS { interface Process { pid: number; send?: (msg: any) => void; exit(code?: number): void; } } + +declare const process: NodeJS.Process + +declare module 'cluster' { + interface Worker { process: { pid: number }; on(event: 'message', cb: (msg: any) => void): void } + const isPrimary: boolean + function fork(): Worker + function on(event: 'exit', cb: (worker: Worker, code: number) => void): void + export { isPrimary, fork, on, Worker } + export default { isPrimary, fork, on } +} + +declare module 'fs' { const x: any; export = x } + +declare module 'path' { const x: any; export = x } + +// Do NOT redeclare 'Page' to avoid erasing actual Playwright types if present. +// If types are missing, install: npm i -D @types/node + diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts new file mode 100644 index 0000000..67a4390 --- /dev/null +++ b/src/util/ConclusionWebhook.ts @@ -0,0 +1,32 @@ +import axios from 'axios' + +import { Config } from '../interface/Config' + +interface ConclusionPayload { + content?: string + embeds?: any[] +} + +/** + * Send a final structured summary to the dedicated conclusion webhook (if enabled), + * otherwise do nothing. Does NOT fallback to the normal logging webhook to avoid spam. + */ +export async function ConclusionWebhook(configData: Config, content: string, embed?: ConclusionPayload) { + const webhook = configData.conclusionWebhook + + if (!webhook || !webhook.enabled || webhook.url.length < 10) return + + const body: ConclusionPayload = embed?.embeds ? { embeds: embed.embeds } : { content } + if (content && !body.content && !body.embeds) body.content = content + + const request = { + method: 'POST', + url: webhook.url, + headers: { + 'Content-Type': 'application/json' + }, + data: body + } + + await axios(request).catch(() => { }) +} diff --git a/tsconfig.json b/tsconfig.json index fa845f5..14dfaa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,9 @@ "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "types": ["node"], // Removed explicit requirement; using local shims. Install @types/node for full typings. + "typeRoots": ["./src/types", "./node_modules/@types"], // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */