feat: Add conclusion webhook support for final summary notifications (#355)

- Updated README.md to include new configuration options for conclusion webhook.
- Enhanced BrowserFunc.ts with improved error handling during page reloads.
- Implemented conclusionWebhook configuration in config.json.
- Refactored Login.ts to use Playwright types and improved passkey handling.
- Added safeClick method in SearchOnBing.ts to handle click timeouts and overlays.
- Introduced account summary collection in index.ts for reporting.
- Created ConclusionWebhook.ts to send structured summaries to a dedicated webhook.
- Updated TypeScript definitions for better type safety across the project.
This commit is contained in:
Light
2025-09-14 08:29:09 +02:00
committed by GitHub
parent 2e80266ad1
commit b66114d4dd
15 changed files with 637 additions and 91 deletions

View File

@@ -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

View File

@@ -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)
}
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}"]`
}

View File

@@ -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<Page> {

View File

@@ -43,5 +43,9 @@
"webhook": {
"enabled": false,
"url": ""
},
"conclusionWebhook": {
"enabled": false,
"url": ""
}
}

View File

@@ -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<string>((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 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()
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen via text fallback')
await page.waitForTimeout(500)
handledPasskey = true
} else {
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(() => { })
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Attempted to close Passkey screen via close button')
await page.waitForTimeout(500)
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<void> {

View File

@@ -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

View File

@@ -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)

View File

@@ -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<string> {
interface Queries {
title: string;

View File

@@ -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) {
// 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)
})()
// 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()
}
@@ -156,6 +248,7 @@ export class MicrosoftRewardsBot {
const data = await this.browser.func.getDashboardData()
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
@@ -225,6 +323,7 @@ export class MicrosoftRewardsBot {
await this.browser.func.goHome(this.homePage)
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() {

View File

@@ -14,6 +14,7 @@ export interface Config {
webhookLogExcludeFunc: string[];
proxy: ConfigProxy;
webhook: ConfigWebhook;
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
}
export interface ConfigSaveFingerprint {

78
src/types/node-shim.d.ts vendored Normal file
View File

@@ -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<T = any> { data: T }
export interface AxiosInstance {
defaults: any
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
}
export interface AxiosStatic {
(config: AxiosRequestConfig): Promise<AxiosResponse>
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
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<string, WorkerLike>
on(event: 'exit', listener: (worker: WorkerLike, code: number) => void): any
}
const cluster: Cluster
export default cluster
}

57
src/types/rebrowser-playwright.d.ts vendored Normal file
View File

@@ -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<Page>
setDefaultTimeout(timeout: number): void
addCookies(cookies: Cookie[]): Promise<void>
cookies(): Promise<Cookie[]>
pages(): Page[]
close(): Promise<void>
}
export interface Browser {
newPage(): Promise<Page>
context(): BrowserContext
close(): Promise<void>
pages?(): Page[]
}
export interface Keyboard {
type(text: string): Promise<any>
press(key: string): Promise<any>
down(key: string): Promise<any>
up(key: string): Promise<any>
}
export interface Locator {
first(): Locator
click(opts?: any): Promise<any>
isVisible(opts?: any): Promise<boolean>
nth(index: number): Locator
}
export interface Page {
goto(url: string, opts?: any): Promise<any>
waitForLoadState(state?: string, opts?: any): Promise<any>
waitForSelector(selector: string, opts?: any): Promise<any>
fill(selector: string, value: string): Promise<any>
keyboard: Keyboard
click(selector: string, opts?: any): Promise<any>
close(): Promise<any>
url(): string
route(match: string, handler: any): Promise<any>
locator(selector: string): Locator
$: (selector: string) => Promise<any>
context(): BrowserContext
reload(opts?: any): Promise<any>
evaluate<R=any>(pageFunction: any, arg?: any): Promise<R>
content(): Promise<string>
waitForTimeout(timeout: number): Promise<void>
}
export interface ChromiumType { launch(opts?: any): Promise<Browser> }
export const chromium: ChromiumType
}
declare module 'rebrowser-playwright' {
export * from 'playwright'
}

25
src/types/shims-node.d.ts vendored Normal file
View File

@@ -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

View File

@@ -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(() => { })
}

View File

@@ -40,6 +40,8 @@
// "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). */
// "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. */