V2.3.0 Optimization (#380)

* Updated README.md to reflect version 2.1 and improve the presentation of Microsoft Rewards Automation features.

* Updated version to 2.1.5 in README.md and package.json, added new license and legal notice sections, and improved the configuration script for a better user experience.

* Mise à jour des messages de journalisation et ajout de vérifications pour le chargement des quiz et la présence des options avant de procéder. Suppression de fichiers de configuration obsolètes.

* Added serial protection dialog management for message forwarding, including closing by button or escape.

* feat: Implement BanPredictor for predicting ban risks based on historical data and real-time events

feat: Add ConfigValidator to validate configuration files and catch common issues

feat: Create QueryDiversityEngine to fetch diverse search queries from multiple sources

feat: Develop RiskManager to monitor account activity and assess risk levels dynamically

* Refactor code for consistency and readability; unify string quotes, improve logging with contextual emojis, enhance configuration validation, and streamline risk management logic.

* feat: Refactor BrowserUtil and Login classes for improved button handling and selector management; implement unified selector system and enhance activity processing logic in Workers class.

* feat: Improve logging with ASCII context icons for better compatibility with Windows PowerShell

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* feat: Update Node.js engine requirement to >=20.0.0 and improve webhook avatar handling and big fix Schedule

* Update README.md

* feat: Improve logging for Google Trends search queries and adjust fallback condition

* feat: Update version to 2.2.1 and enhance dashboard data retrieval with improved error handling

* feat: Update version to 2.2.2 and add terms update dialog dismissal functionality

* feat: Update version to 2.2.2 and require Node.js engine >=20.0.0

* feat: Ajouter un fichier de configuration complet pour la gestion des tâches et des performances

* feat: Mettre à jour la version à 2.2.3, modifier le fuseau horaire par défaut et activer les rapports d'analyse

* feat: update doc

* feat: update doc

* Refactor documentation for proxy setup, security guide, and auto-update system

- Updated proxy documentation to streamline content and improve clarity.
- Revised security guide to emphasize best practices and incident response.
- Simplified auto-update documentation, enhancing user understanding of the update process.
- Removed redundant sections and improved formatting for better readability.

* feat: update version to 2.2.7 in package.json

* feat: update version to 2.2.7 in README.md

* feat: improve quiz data retrieval with alternative variables and debug logs

* feat: refactor timeout and selector constants for improved maintainability

* feat: update version to 2.2.8 in package.json and add retry limits in constants

* feat: enhance webhook logging with username, avatar, and color-coded messages

* feat: update .gitignore to include diagnostic folder and bump version to 2.2.8 in package-lock.json

* feat: updated version to 2.3.0 and added new constants to improve the handling of delays and colors in logs
This commit is contained in:
Light
2025-10-16 17:59:53 +02:00
committed by GitHub
parent 4d928d7dd9
commit abd6117db3
37 changed files with 2392 additions and 4104 deletions

View File

@@ -4,9 +4,10 @@ import { AxiosRequestConfig } from 'axios'
import { MicrosoftRewardsBot } from '../index'
import { saveSessionData } from '../util/Load'
import { TIMEOUTS, RETRY_LIMITS, SELECTORS, URLS } from '../constants'
import { Counters, DashboardData, MorePromotion, PromotionalItem } from './../interface/DashboardData'
import { QuizData } from './../interface/QuizData'
import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
import { QuizData } from '../interface/QuizData'
import { AppUserData } from '../interface/AppUserData'
import { EarnablePoints } from '../interface/Points'
@@ -34,34 +35,47 @@ export default class BrowserFunc {
await page.goto(this.bot.config.baseURL)
const maxIterations = 5 // Maximum iterations set to 5
for (let iteration = 1; iteration <= maxIterations; iteration++) {
await this.bot.utils.wait(3000)
for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) {
await this.bot.utils.wait(TIMEOUTS.LONG)
await this.bot.browser.utils.tryDismissAllMessages(page)
// Check if account is suspended (multiple heuristics)
const suspendedByHeader = await page.waitForSelector('#suspendedAccountHeader', { state: 'visible', timeout: 1500 }).then(() => true).catch(() => false)
let suspendedByText = false
if (!suspendedByHeader) {
try {
const text = (await page.textContent('body')) || ''
suspendedByText = /account has been suspended|suspended due to unusual activity/i.test(text)
} catch { /* ignore */ }
}
if (suspendedByHeader || suspendedByText) {
this.bot.log(this.bot.isMobile, 'GO-HOME', 'This account appears suspended!', 'error')
throw new Error('Account has been suspended!')
}
try {
// If activities are found, exit the loop
await page.waitForSelector('#more-activities', { timeout: 1000 })
// If activities are found, exit the loop (SUCCESS - account is OK)
await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 })
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
break
} catch (error) {
// Continue if element is not found
// Activities not found yet - check if it's because account is suspended
// Only check suspension if we can't find activities (reduces false positives)
const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }).then(() => true).catch(() => false)
if (suspendedByHeader) {
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error')
throw new Error('Account has been suspended!')
}
// Secondary check: look for suspension text in main content area only
try {
const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || ''
const suspensionPatterns = [
/account\s+has\s+been\s+suspended/i,
/suspended\s+due\s+to\s+unusual\s+activity/i,
/your\s+account\s+is\s+temporarily\s+suspended/i
]
const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent))
if (isSuspended) {
this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error')
throw new Error('Account has been suspended!')
}
} catch (e) {
// Ignore errors in text check - not critical
this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn')
}
// Not suspended, just activities not loaded yet - continue to next iteration
this.bot.log(this.bot.isMobile, 'GO-HOME', `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, 'warn')
}
// Below runs if the homepage was unable to be visited
@@ -70,14 +84,14 @@ export default class BrowserFunc {
if (currentURL.hostname !== dashboardURL.hostname) {
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.goto(this.bot.config.baseURL)
} else {
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
break
}
await this.bot.utils.wait(5000)
await this.bot.utils.wait(TIMEOUTS.VERY_LONG)
}
} catch (error) {
@@ -127,6 +141,14 @@ export default class BrowserFunc {
}
}
// Wait a bit longer for scripts to load, especially on mobile
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
// Wait for the more-activities element to ensure page is fully loaded
await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn')
})
let scriptContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
@@ -135,18 +157,36 @@ export default class BrowserFunc {
})
if (!scriptContent) {
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch(()=>{})
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
})
// Force a navigation retry once before failing hard
try {
await this.goHome(target)
await target.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(()=>{})
} catch {/* ignore */}
await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Wait for load state failed: ${e}`, 'warn')
})
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
} catch (e) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn')
}
const retryContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
return targetScript?.innerText ? targetScript.innerText : null
}).catch(()=>null)
if (!retryContent) {
// Log additional debug info
const scriptsDebug = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
return scripts.map(s => s.innerText.substring(0, 100)).join(' | ')
}).catch(() => 'Unable to get script debug info')
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn')
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
}
scriptContent = retryContent
@@ -154,18 +194,37 @@ export default class BrowserFunc {
// Extract the dashboard object from the script content
const dashboardData = await target.evaluate((scriptContent: string) => {
// Extract the dashboard object using regex
const regex = /var dashboard = (\{.*?\});/s
const match = regex.exec(scriptContent)
// Try multiple regex patterns for better compatibility
const patterns = [
/var dashboard = (\{.*?\});/s, // Original pattern
/var dashboard=(\{.*?\});/s, // No spaces
/var\s+dashboard\s*=\s*(\{.*?\});/s, // Flexible whitespace
/dashboard\s*=\s*(\{[\s\S]*?\});/ // More permissive
]
if (match && match[1]) {
return JSON.parse(match[1])
for (const regex of patterns) {
const match = regex.exec(scriptContent)
if (match && match[1]) {
try {
return JSON.parse(match[1])
} catch (e) {
// Try next pattern if JSON parsing fails
continue
}
}
}
return null
}, scriptContent)
if (!dashboardData) {
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch(()=>{})
// Log a snippet of the script content for debugging
const scriptPreview = scriptContent.substring(0, 200)
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Script preview: ${scriptPreview}`, 'warn')
await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch((e) => {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Failed to capture diagnostics: ${e}`, 'warn')
})
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
}
@@ -263,7 +322,7 @@ export default class BrowserFunc {
: 'us'
const userDataRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613',
url: URLS.APP_USER_DATA,
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
@@ -319,38 +378,73 @@ export default class BrowserFunc {
*/
async getQuizData(page: Page): Promise<QuizData> {
try {
// Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded')
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
const html = await page.content()
const $ = load(html)
const scriptContent = $('script')
.toArray()
.map(el => $(el).text())
.find(t => t.includes('_w.rewardsQuizRenderInfo')) || ''
// Try multiple possible variable names
const possibleVariables = [
'_w.rewardsQuizRenderInfo',
'rewardsQuizRenderInfo',
'_w.quizRenderInfo',
'quizRenderInfo'
]
if (scriptContent) {
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
let scriptContent = ''
let foundVariable = ''
for (const varName of possibleVariables) {
scriptContent = $('script')
.toArray()
.map(el => $(el).text())
.find(t => t.includes(varName)) || ''
if (scriptContent) {
foundVariable = varName
break
}
}
if (scriptContent && foundVariable) {
// Escape dots in variable name for regex
const escapedVar = foundVariable.replace(/\./g, '\\.')
const regex = new RegExp(`${escapedVar}\\s*=\\s*({.*?});`, 's')
const match = regex.exec(scriptContent)
if (match && match[1]) {
const quizData = JSON.parse(match[1])
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found quiz data using variable: ${foundVariable}`, 'log')
return quizData
} else {
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Quiz data not found within script', 'error')
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
}
} else {
// Log available scripts for debugging
const allScripts = $('script')
.toArray()
.map(el => $(el).text())
.filter(t => t.length > 0)
.map(t => t.substring(0, 100))
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error')
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn')
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
}
} catch (error) {
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred:' + error, 'error')
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + error, 'error')
}
}
async waitForQuizRefresh(page: Page): Promise<boolean> {
try {
await page.waitForSelector('span.rqMCredits', { state: 'visible', timeout: 10000 })
await this.bot.utils.wait(2000)
await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
@@ -361,8 +455,8 @@ export default class BrowserFunc {
async checkQuizCompleted(page: Page): Promise<boolean> {
try {
await page.waitForSelector('#quizCompleteContainer', { state: 'visible', timeout: 2000 })
await this.bot.utils.wait(2000)
await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG })
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
return true
} catch (error) {
@@ -402,7 +496,7 @@ export default class BrowserFunc {
// Save cookies
await saveSessionData(this.bot.config.sessionPath, browser, email, this.bot.isMobile)
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
// Close browser
await browser.close()

View File

@@ -40,6 +40,12 @@ export default class BrowserUtil {
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
} as const
private static readonly TERMS_UPDATE_SELECTORS = {
titleId: '#iTOUTitle',
titleText: /we're updating our terms/i,
nextButton: 'button[data-testid="primaryButton"]:has-text("Next"), button[type="submit"]:has-text("Next")'
} as const
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
@@ -57,6 +63,7 @@ export default class BrowserUtil {
count += await this.dismissStandardButtons(page)
count += await this.dismissOverlayButtons(page)
count += await this.dismissStreakDialog(page)
count += await this.dismissTermsUpdateDialog(page)
return count
}
@@ -135,6 +142,35 @@ export default class BrowserUtil {
}
}
private async dismissTermsUpdateDialog(page: Page): Promise<number> {
try {
const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS
// Check if terms update page is present
const titleById = page.locator(titleId)
const titleByText = page.locator('h1').filter({ hasText: titleText })
const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) ||
await titleByText.first().isVisible({ timeout: 200 }).catch(() => false)
if (!hasTitle) return 0
// Click the Next button
const nextBtn = page.locator(nextButton).first()
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await nextBtn.click({ timeout: 1000 }).catch(() => {})
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
// Wait a bit for navigation
await page.waitForTimeout(1000)
return 1
}
return 0
} catch {
return 0
}
}
async getLatestTab(page: Page): Promise<Page> {
try {
await this.bot.utils.wait(1000)

View File

@@ -1,54 +1,112 @@
{
// Base URL for Rewards dashboard and APIs (do not change unless you know what you're doing)
// ============================================================
// 🌐 GENERAL CONFIGURATION
// ============================================================
// Base URL for Microsoft Rewards dashboard (do not change unless necessary)
"baseURL": "https://rewards.bing.com",
// Where to store sessions (cookies, fingerprints)
// Directory to store sessions (cookies, browser fingerprints)
"sessionPath": "sessions",
// Dry-run mode: simulate execution without actually running tasks (useful for testing)
"dryRun": false,
// ============================================================
// 🖥️ BROWSER CONFIGURATION
// ============================================================
"browser": {
// Keep headless=false so the browser window stays visible by default
// false = visible window | true = headless mode (invisible)
"headless": false,
// Max time to wait for common operations (supports ms/s/min: e.g. 30000, "30s", "2min")
// Max timeout for operations (supports: 30000, "30s", "2min")
"globalTimeout": "30s"
},
"execution": {
// Run desktop+mobile in parallel (needs more resources). If false, runs sequentially.
"parallel": false,
// If false and there are 0 points available, the run is skipped early to save time.
"runOnZeroPoints": false,
// Number of account clusters (processes) to run concurrently.
"clusters": 1,
// Number of passes per invocation (advanced; usually 1).
"passesPerRun": 1
},
"buyMode": {
// Manual purchase/redeem mode. Use CLI -buy to enable, or set buyMode.enabled in config.
// Session duration cap in minutes.
"maxMinutes": 45
},
"fingerprinting": {
// Persist browser fingerprints per device type to improve consistency across runs
// Persist browser fingerprints to improve consistency across runs
"saveFingerprint": {
"mobile": true,
"desktop": true
}
},
// ============================================================
// ⚙️ EXECUTION & PERFORMANCE
// ============================================================
"execution": {
// true = Desktop + Mobile in parallel (faster, more resources)
// false = Sequential (slower, fewer resources)
"parallel": false,
// If false, skip execution when 0 points are available
"runOnZeroPoints": false,
// Number of account clusters (processes) to run concurrently
"clusters": 1,
// Number of passes per invocation (usually 1)
"passesPerRun": 1
},
"schedule": {
// Built-in scheduler (no cron needed in containers)
"enabled": false,
// Time format options:
// - US style with AM/PM → useAmPm: true and time12 (e.g., "9:00 AM")
// - 24-hour style → useAmPm: false and time24 (e.g., "09:00")
"useAmPm": false,
"time12": "9:00 AM",
"time24": "09:00",
// IANA timezone (e.g., "Europe/Paris", "America/New_York" check schedule.md)
"timeZone": "Europe/Paris",
// If true, run immediately on process start
"runImmediatelyOnStart": false
},
"jobState": {
// Save state to avoid duplicate work across restarts
"enabled": true,
// Custom state directory (empty = defaults to sessionPath/job-state)
"dir": ""
},
// ============================================================
// 🎯 TASKS & WORKERS
// ============================================================
"workers": {
// Select which tasks the bot should complete on desktop/mobile
"doDailySet": true, // Daily set tasks
"doMorePromotions": true, // More promotions section
"doPunchCards": true, // Punch cards
"doDesktopSearch": true, // Desktop searches
"doMobileSearch": true, // Mobile searches
"doDailyCheckIn": true, // Daily check-in
"doReadToEarn": true, // Read to earn
// If true, run desktop searches right after Daily Set
"bundleDailySetWithSearch": true
},
// ============================================================
// 🔍 SEARCH CONFIGURATION
// ============================================================
"search": {
// Use locale-specific query sources
"useLocalQueries": true,
"settings": {
// Add geo/locale signal into query selection
// Use region-specific queries (at, fr, us, etc.)
"useGeoLocaleQueries": true,
// Randomly scroll search result pages to look more natural
// Randomly scroll search result pages (more natural behavior)
"scrollRandomResults": true,
// Occasionally click a result (safe targets only)
"clickRandomResults": true,
// Number of times to retry mobile searches if points didnt progress
// Number of retries if mobile searches don't progress
"retryMobileSearchAmount": 2,
// Delay between searches (supports numbers in ms or time strings)
// Delay between searches
"delay": {
"min": "1min",
"max": "5min"
@@ -56,38 +114,70 @@
}
},
"humanization": {
// Global Human Mode switch. true=adds subtle micro-gestures & pauses. false=classic behavior.
"queryDiversity": {
// Multi-source query generation: Reddit, News, Wikipedia instead of only Google Trends
"enabled": true,
// If true, as soon as a ban is detected on any account, stop processing remaining accounts
// (ban detection is based on centralized heuristics and error signals)
// Available sources: google-trends, reddit, news, wikipedia, local-fallback
"sources": ["google-trends", "reddit", "local-fallback"],
// Max queries to fetch per source
"maxQueriesPerSource": 10,
// Cache duration in minutes (avoids hammering APIs)
"cacheMinutes": 30
},
// ============================================================
// 🤖 HUMANIZATION & NATURAL BEHAVIOR
// ============================================================
"humanization": {
// Human Mode: adds subtle micro-gestures & pauses to mimic real users
"enabled": true,
// If a ban is detected on any account, stop processing remaining accounts
"stopOnBan": true,
// If true, immediately send an alert (webhook/NTFY) when a ban is detected
// Immediately send an alert (webhook/NTFY) when a ban is detected
"immediateBanAlert": true,
// Extra random pause between actions (ms or time string e.g., "300ms", "1s")
// Extra random pause between actions
"actionDelay": {
"min": 500,
"max": 2200
"min": 500, // 0.5 seconds minimum
"max": 2200 // 2.2 seconds maximum
},
// Probability (0..1) to move mouse a tiny bit in between actions
// Probability (0-1) to move mouse slightly between actions
"gestureMoveProb": 0.65,
// Probability (0..1) to perform a very small scroll
// Probability (0-1) to perform a small scroll
"gestureScrollProb": 0.4,
// Optional local-time windows for execution (e.g., ["08:30-11:00", "19:00-22:00"]).
// If provided, runs will wait until inside a window before starting.
// Optional execution time windows (e.g., ["08:30-11:00", "19:00-22:00"])
// If specified, waits until inside a window before starting
"allowedWindows": []
},
// Optional monthly "vacation" block: skip a contiguous range of days to look more human.
// This is independent of weekly random off-days. When enabled, each month a random
// block between minDays and maxDays is selected (e.g., 24 days) and all runs within
// that date range are skipped. The chosen block is logged at the start of the month.
"vacation": {
// Monthly "vacation" block: skip a random range of days each month
// Each month, a random period between minDays and maxDays is selected
// and all runs within that date range are skipped (more human-like behavior)
"enabled": true,
"minDays": 2,
"maxDays": 4
},
// ============================================================
// 🛡️ RISK MANAGEMENT & SECURITY
// ============================================================
"riskManagement": {
// Dynamic delay adjustment based on detected risk signals
"enabled": true,
// Automatically increase delays when captchas/errors are detected
"autoAdjustDelays": true,
// Stop execution if risk level reaches critical threshold
"stopOnCritical": false,
// Enable ML-based ban prediction based on patterns
"banPrediction": true,
// Risk threshold (0-100). If exceeded, bot pauses or alerts you
"riskThreshold": 75
},
"retryPolicy": {
// Generic retry/backoff for transient failures
"maxAttempts": 3,
@@ -97,18 +187,10 @@
"jitter": 0.2
},
"workers": {
// Select what the bot should complete on desktop/mobile
"doDailySet": true,
"doMorePromotions": true,
"doPunchCards": true,
"doDesktopSearch": true,
"doMobileSearch": true,
"doDailyCheckIn": true,
"doReadToEarn": true,
// If true, run a desktop search bundle right after Daily Set
"bundleDailySetWithSearch": true
},
// ============================================================
// 🌐 PROXY
// ============================================================
"proxy": {
// Control which outbound calls go through your proxy
@@ -116,13 +198,18 @@
"proxyBingTerms": true
},
// ============================================================
// 🔔 NOTIFICATIONS
// ============================================================
"notifications": {
// Live logs (Discord or similar). URL is your webhook endpoint.
// Live logs webhook (Discord or similar). URL = your webhook endpoint
"webhook": {
"enabled": false,
"url": ""
},
// Rich end-of-run summary (Discord or similar)
// Rich end-of-run summary webhook (Discord or similar)
"conclusionWebhook": {
"enabled": false,
"url": ""
@@ -136,9 +223,14 @@
}
},
// ============================================================
// 📊 LOGGING & DIAGNOSTICS
// ============================================================
"logging": {
// Logging controls (see docs/config.md). Remove redactEmails or set false to show full emails.
// Filter out noisy log buckets locally and for any webhook summaries
// Logging controls (see docs/config.md)
// Filter out noisy log buckets locally and for webhook summaries
"excludeFunc": [
"SEARCH-CLOSE-TABS",
"LOGIN-NO-PROMPT",
@@ -149,12 +241,12 @@
"LOGIN-NO-PROMPT",
"FLOW"
],
// Email redaction toggle (previously logging.live.redactEmails)
// Email redaction toggle (true = secure, false = full emails)
"redactEmails": true
},
"diagnostics": {
// Capture minimal evidence on failures (screenshots/HTML) and prune old runs
// Capture minimal evidence on failures (screenshots/HTML)
"enabled": true,
"saveScreenshot": true,
"saveHtml": true,
@@ -162,75 +254,45 @@
"retentionDays": 7
},
"jobState": {
// Checkpoint to avoid duplicate work across restarts
"enabled": true,
// Custom state directory (defaults to sessionPath/job-state if empty)
"dir": ""
},
"schedule": {
// Built-in scheduler (no cron needed in container). Uses the IANA time zone below.
"enabled": false,
// Choose YOUR preferred time format:
// - US style with AM/PM → set useAmPm: true and edit time12 (e.g., "9:00 AM")
// - 24-hour style → set useAmPm: false and edit time24 (e.g., "09:00")
// Back-compat: if both time12/time24 are empty, the legacy "time" (HH:mm) will be used if present.
"useAmPm": false,
"time12": "9:00 AM",
"time24": "09:00",
// IANA timezone for scheduling (set to your region), e.g. "Europe/Paris" or "America/New_York"
"timeZone": "America/New_York",
// If true, run one pass immediately when the process starts
"runImmediatelyOnStart": false
},
"update": {
// Optional post-run auto-update
"git": true,
"docker": false,
// Custom updater script path (relative to repo root)
"scriptPath": "setup/update/update.mjs"
},
// NEW INTELLIGENT FEATURES
"riskManagement": {
// Risk-Aware Throttling: dynamically adjusts delays based on detected risk signals
"enabled": true,
// Automatically increase delays when captchas/errors are detected
"autoAdjustDelays": true,
// Stop execution if risk level reaches critical (score > riskThreshold)
"stopOnCritical": false,
// Enable ML-style ban prediction based on patterns
"banPrediction": true,
// Risk threshold (0-100). If exceeded, bot pauses or alerts you.
"riskThreshold": 75
},
"analytics": {
// Performance Dashboard: track points earned, success rates, execution times
// 📈 Performance Dashboard: tracks points earned, success rates, execution times
// Useful for monitoring your stats over time. Disable if you don't need it.
// WHAT IT DOES:
// - Collects daily/weekly/monthly statistics
// - Calculates success rates for each activity type
// - Tracks average execution times
// - Generates trend reports
// - Can export to Markdown or send via webhook
"enabled": true,
// How long to keep analytics data (days)
"retentionDays": 30,
// Generate markdown summary reports
"exportMarkdown": true,
// Send analytics summary via webhook
"webhookSummary": false
"webhookSummary": true
},
"queryDiversity": {
// Multi-source query generation: use Reddit, News, Wikipedia instead of just Google Trends
"enabled": true,
// Which sources to use (google-trends, reddit, news, wikipedia, local-fallback)
"sources": ["google-trends", "reddit", "local-fallback"],
// Max queries to fetch per source
"maxQueriesPerSource": 10,
// Cache duration in minutes (avoids hammering APIs)
"cacheMinutes": 30
// ============================================================
// 🛒 BUY MODE
// ============================================================
"buyMode": {
// Manual purchase/redeem mode. Use CLI -buy to enable
// Session duration cap in minutes
"maxMinutes": 45
},
// Dry-run mode: simulate execution without actually running tasks (useful for testing config)
"dryRun": false
// ============================================================
// 🔄 UPDATES
// ============================================================
"update": {
// Post-run auto-update settings
"git": true,
"docker": false,
// Custom updater script path (relative to repo root)
"scriptPath": "setup/update/update.mjs"
}
}

67
src/constants.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Central constants file for the Microsoft Rewards Script
* Defines timeouts, retry limits, and other magic numbers used throughout the application
*/
export const TIMEOUTS = {
SHORT: 500,
MEDIUM: 1500,
MEDIUM_LONG: 2000,
LONG: 3000,
VERY_LONG: 5000,
EXTRA_LONG: 10000,
DASHBOARD_WAIT: 10000,
LOGIN_MAX: 180000, // 3 minutes
NETWORK_IDLE: 5000
} as const
export const RETRY_LIMITS = {
MAX_ITERATIONS: 5,
DASHBOARD_RELOAD: 2,
MOBILE_SEARCH: 3,
ABC_MAX: 15,
POLL_MAX: 15,
QUIZ_MAX: 15,
QUIZ_ANSWER_TIMEOUT: 10000,
GO_HOME_MAX: 5
} as const
export const DELAYS = {
ACTION_MIN: 1000,
ACTION_MAX: 3000,
SEARCH_DEFAULT_MIN: 2000,
SEARCH_DEFAULT_MAX: 5000,
BROWSER_CLOSE: 2000,
TYPING_DELAY: 20,
SEARCH_ON_BING_WAIT: 5000,
SEARCH_ON_BING_COMPLETE: 3000,
SEARCH_ON_BING_FOCUS: 200,
SEARCH_BAR_TIMEOUT: 15000,
QUIZ_ANSWER_WAIT: 2000,
THIS_OR_THAT_START: 2000
} as const
export const SELECTORS = {
MORE_ACTIVITIES: '#more-activities',
SUSPENDED_ACCOUNT: '#suspendedAccountHeader',
QUIZ_COMPLETE: '#quizCompleteContainer',
QUIZ_CREDITS: 'span.rqMCredits'
} as const
export const URLS = {
REWARDS_BASE: 'https://rewards.bing.com',
REWARDS_SIGNIN: 'https://rewards.bing.com/signin',
APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613'
} as const
export const DISCORD = {
MAX_EMBED_LENGTH: 1900,
RATE_LIMIT_DELAY: 500,
WEBHOOK_TIMEOUT: 10000,
DEBOUNCE_DELAY: 750,
COLOR_RED: 0xFF0000,
COLOR_CRIMSON: 0xDC143C,
COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB,
COLOR_GREEN: 0x00D26A
} as const

View File

@@ -202,6 +202,10 @@ export class Login {
// --------------- 2FA Handling ---------------
private async handle2FA(page: Page) {
try {
// Dismiss any popups/dialogs before checking 2FA (Terms Update, etc.)
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(500)
if (this.currentTotpSecret) {
const totpSelector = await this.ensureTotpInput(page)
if (totpSelector) {
@@ -273,10 +277,45 @@ export class Login {
} catch {/* ignore */}
}
// Manual prompt
// Manual prompt with periodic page check
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
const code: string = await new Promise(res => rl.question('Enter 2FA code:\n', ans => { rl.close(); res(ans.trim()) }))
// Monitor page changes while waiting for user input
let userInput: string | null = null
let checkInterval: NodeJS.Timeout | null = null
const inputPromise = new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (checkInterval) clearInterval(checkInterval)
rl.close()
res(ans.trim())
})
})
// Check every 2 seconds if user manually progressed past the dialog
checkInterval = setInterval(async () => {
try {
await this.bot.browser.utils.tryDismissAllMessages(page)
// Check if we're no longer on 2FA page
const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
if (!still2FA) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
if (checkInterval) clearInterval(checkInterval)
rl.close()
userInput = 'skip' // Signal to skip submission
}
} catch {/* ignore */}
}, 2000)
const code = await inputPromise
if (checkInterval) clearInterval(checkInterval)
if (code === 'skip' || userInput === 'skip') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)')
return
}
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
@@ -729,7 +768,7 @@ export class Login {
}
private getDocsUrl(anchor?: string) {
const base = process.env.DOCS_BASE?.trim() || 'https://github.com/LightZirconite/Microsoft-Rewards-Script-Private/blob/V2/docs/security.md'
const base = process.env.DOCS_BASE?.trim() || 'https://github.com/LightZirconite/Microsoft-Rewards-Script-Private/blob/v2/docs/security.md'
const map: Record<string,string> = {
'recovery-email-mismatch':'#recovery-email-mismatch',
'we-cant-sign-you-in':'#we-cant-sign-you-in-blocked'

View File

@@ -1,6 +1,7 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS } from '../../constants'
export class ABC extends Workers {
@@ -11,34 +12,32 @@ export class ABC extends Workers {
try {
let $ = await this.bot.browser.func.loadInCheerio(page)
// Don't loop more than 15 in case unable to solve, would lock otherwise
const maxIterations = 15
let i
for (i = 0; i < maxIterations && !$('span.rw_icon').length; i++) {
await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: 10000 })
for (i = 0; i < RETRY_LIMITS.ABC_MAX && !$('span.rw_icon').length; i++) {
await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
const answers = $('.wk_OptionClickClass')
const answer = answers[this.bot.utils.randomNumber(0, 2)]?.attribs['id']
await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: 10000 })
await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(`#${answer}`) // Click answer
await this.bot.utils.wait(4000)
await page.waitForSelector('div.wk_button', { state: 'visible', timeout: 10000 })
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.waitForSelector('div.wk_button', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
await page.click('div.wk_button') // Click next question button
page = await this.bot.browser.utils.getLatestTab(page)
$ = await this.bot.browser.func.loadInCheerio(page)
await this.bot.utils.wait(1000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
}
await this.bot.utils.wait(4000)
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
if (i === maxIterations) {
this.bot.log(this.bot.isMobile, 'ABC', 'Failed to solve quiz, exceeded max iterations of 15', 'warn')
if (i === RETRY_LIMITS.ABC_MAX) {
this.bot.log(this.bot.isMobile, 'ABC', `Failed to solve quiz, exceeded max iterations of ${RETRY_LIMITS.ABC_MAX}`, 'warn')
} else {
this.bot.log(this.bot.isMobile, 'ABC', 'Completed the ABC successfully')
}

View File

@@ -1,6 +1,7 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { TIMEOUTS } from '../../constants'
export class Poll extends Workers {
@@ -11,12 +12,14 @@ export class Poll extends Workers {
try {
const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
await page.waitForSelector(buttonId, { state: 'visible', timeout: 10000 }).catch(() => { }) // We're gonna click regardless or not
await this.bot.utils.wait(2000)
await page.waitForSelector(buttonId, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((e) => {
this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${e}`, 'warn')
})
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
await page.click(buttonId)
await this.bot.utils.wait(4000)
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
await page.close()
this.bot.log(this.bot.isMobile, 'POLL', 'Completed the poll successfully')

View File

@@ -1,6 +1,7 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { RETRY_LIMITS, TIMEOUTS, DELAYS } from '../../constants'
export class Quiz extends Workers {
@@ -10,19 +11,19 @@ export class Quiz extends Workers {
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz has already been started, trying to finish it')
}
await this.bot.utils.wait(2000)
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
let quizData = await this.bot.browser.func.getQuizData(page)
// Verify quiz is actually loaded before proceeding
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: 5000 }).then(() => true).catch(() => false)
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: TIMEOUTS.VERY_LONG }).then(() => true).catch(() => false)
if (!firstOptionExists) {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn')
await page.close()
@@ -37,7 +38,7 @@ export class Quiz extends Workers {
const answers: string[] = []
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 }).catch(() => null)
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn')
@@ -60,7 +61,7 @@ export class Quiz extends Workers {
// Click the answers
for (const answer of answers) {
await page.waitForSelector(answer, { state: 'visible', timeout: 2000 })
await page.waitForSelector(answer, { state: 'visible', timeout: DELAYS.QUIZ_ANSWER_WAIT })
// Click the answer on page
await page.click(answer)
@@ -82,7 +83,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 }).catch(() => null)
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT }).catch(() => null)
if (!answerSelector) {
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
@@ -112,12 +113,12 @@ export class Quiz extends Workers {
return
}
await this.bot.utils.wait(2000)
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
}
}
// Done with
await this.bot.utils.wait(2000)
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')

View File

@@ -277,8 +277,10 @@ export class Search extends Workers {
}
const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)])
if (mappedTrendsData.length < 90) {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'Insufficient search queries, falling back to US', 'warn')
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Found ${mappedTrendsData.length} search queries for ${geoLocale}`)
if (mappedTrendsData.length < 30 && geoLocale.toUpperCase() !== 'US') {
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Insufficient search queries (${mappedTrendsData.length} < 30), falling back to US`, 'warn')
return this.getGoogleTrends()
}

View File

@@ -3,6 +3,7 @@ import * as fs from 'fs'
import path from 'path'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
import { MorePromotion, PromotionalItem } from '../../interface/DashboardData'
@@ -13,7 +14,7 @@ export class SearchOnBing extends Workers {
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Trying to complete SearchOnBing')
try {
await this.bot.utils.wait(5000)
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_WAIT)
await this.bot.browser.utils.tryDismissAllMessages(page)
@@ -21,20 +22,20 @@ export class SearchOnBing extends Workers {
const searchBar = '#sb_form_q'
const box = page.locator(searchBar)
await box.waitFor({ state: 'attached', timeout: 15000 })
await box.waitFor({ state: 'attached', timeout: DELAYS.SEARCH_BAR_TIMEOUT })
await this.bot.browser.utils.tryDismissAllMessages(page)
await this.bot.utils.wait(200)
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
try {
await box.focus({ timeout: 2000 }).catch(() => { /* ignore */ })
await box.focus({ timeout: DELAYS.THIS_OR_THAT_START }).catch(() => { /* ignore */ })
await box.fill('')
await this.bot.utils.wait(200)
await page.keyboard.type(query, { delay: 20 })
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
await page.keyboard.type(query, { delay: DELAYS.TYPING_DELAY })
await page.keyboard.press('Enter')
} catch {
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
await page.goto(url)
}
await this.bot.utils.wait(3000)
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_COMPLETE)
await page.close()

View File

@@ -1,6 +1,7 @@
import { Page } from 'rebrowser-playwright'
import { Workers } from '../Workers'
import { DELAYS } from '../../constants'
export class ThisOrThat extends Workers {
@@ -11,14 +12,14 @@ export class ThisOrThat extends Workers {
try {
// Check if the quiz has been started or not
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: DELAYS.THIS_OR_THAT_START }).then(() => true).catch(() => false)
if (quizNotStarted) {
await page.click('#rqStartQuiz')
} else {
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
}
await this.bot.utils.wait(2000)
await this.bot.utils.wait(DELAYS.THIS_OR_THAT_START)
// Solving
const quizData = await this.bot.browser.func.getQuizData(page)

View File

@@ -9,7 +9,7 @@ export class UrlReward extends Workers {
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Trying to complete UrlReward')
try {
this.bot.utils.wait(2000)
await this.bot.utils.wait(2000)
await page.close()

View File

@@ -10,6 +10,7 @@ import BrowserUtil from './browser/BrowserUtil'
import { log } from './util/Logger'
import Util from './util/Utils'
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
import { DISCORD } from './constants'
import { Login } from './functions/Login'
import { Workers } from './functions/Workers'
@@ -66,8 +67,7 @@ export class MicrosoftRewardsBot {
private heartbeatFile?: string
private heartbeatTimer?: NodeJS.Timeout
//@ts-expect-error Will be initialized later
public axios: Axios
public axios!: Axios
constructor(isMobile: boolean) {
this.isMobile = isMobile
@@ -650,10 +650,15 @@ export class MicrosoftRewardsBot {
// Cleanup heartbeat timer/file at end of run
if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } }
if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } }
// After conclusion, run optional auto-update
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
// After conclusion, run optional auto-update (only if not in scheduler mode)
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
process.exit()
}
process.exit()
}
/** Send immediate ban alert if configured. */
@@ -669,7 +674,7 @@ export class MicrosoftRewardsBot {
{
title,
description: desc,
color: 0xFF0000
color: DISCORD.COLOR_RED
}
]
})
@@ -956,20 +961,7 @@ export class MicrosoftRewardsBot {
let accountsWithErrors = 0
let successes = 0
type DiscordField = { name: string; value: string; inline?: boolean }
type DiscordFooter = { text: string }
type DiscordEmbed = {
title?: string
description?: string
color?: number
fields?: DiscordField[]
timestamp?: string
footer?: DiscordFooter
}
const accountFields: DiscordField[] = []
const accountLines: string[] = []
// Calculate summary statistics
for (const s of summaries) {
totalCollected += s.totalCollected
totalInitial += s.initialTotal
@@ -977,44 +969,12 @@ export class MicrosoftRewardsBot {
totalDuration += s.durationMs
if (s.errors.length) accountsWithErrors++
else successes++
const statusEmoji = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
const diff = s.totalCollected
const duration = formatDuration(s.durationMs)
// Build embed fields (Discord)
const valueLines: string[] = [
`Points: ${s.initialTotal}${s.endTotal} ( +${diff} )`,
`Breakdown: 🖥️ ${s.desktopCollected} | 📱 ${s.mobileCollected}`,
`Duration: ⏱️ ${duration}`
]
if (s.banned?.status) {
valueLines.push(`Banned: ${s.banned.reason || 'detected by heuristics'}`)
}
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
})
// Build plain text lines (NTFY)
const lines = [
`${statusEmoji} ${s.email}`,
` Points: ${s.initialTotal}${s.endTotal} ( +${diff} )`,
` 🖥️ ${s.desktopCollected} | 📱 ${s.mobileCollected}`,
` Duration: ${duration}`
]
if (s.banned?.status) lines.push(` Banned: ${s.banned.reason || 'detected by heuristics'}`)
if (s.errors.length) lines.push(` Errors: ${s.errors.slice(0, 2).join(' | ')}`)
accountLines.push(lines.join('\n') + '\n')
}
const avgDuration = totalDuration / totalAccounts
const avgPointsPerAccount = Math.round(totalCollected / totalAccounts)
// Read package version (best-effort)
// Read package version
let version = 'unknown'
try {
const pkgPath = path.join(process.cwd(), 'package.json')
@@ -1025,80 +985,70 @@ export class MicrosoftRewardsBot {
}
} catch { /* ignore */ }
// Discord/Webhook embeds with chunking (limits: 10 embeds/message, 25 fields/embed)
const MAX_EMBEDS = 10
const MAX_FIELDS = 25
// Build clean embed with account details
type DiscordField = { name: string; value: string; inline?: boolean }
type DiscordEmbed = {
title?: string
description?: string
color?: number
fields?: DiscordField[]
thumbnail?: { url: string }
timestamp?: string
footer?: { text: string; icon_url?: string }
}
const baseFields = [
{
name: 'Global Totals',
value: [
`Total Points: ${totalInitial}${totalEnd} ( +${totalCollected} )`,
`Accounts: ✅ ${successes} • ⚠️ ${accountsWithErrors} (of ${totalAccounts})`,
`Average Duration: ${formatDuration(avgDuration)}`,
`Cumulative Runtime: ${formatDuration(totalDuration)}`
].join('\n')
}
]
const accountDetails: string[] = []
for (const s of summaries) {
const statusIcon = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
const line = `${statusIcon} **${s.email}** → +${s.totalCollected}pts (🖥️${s.desktopCollected} 📱${s.mobileCollected}) • ${formatDuration(s.durationMs)}`
accountDetails.push(line)
if (s.banned?.status) accountDetails.push(` └ Banned: ${s.banned.reason || 'detected'}`)
if (s.errors.length > 0) accountDetails.push(` └ Errors: ${s.errors.slice(0, 2).join(', ')}`)
}
// Prepare embeds: first embed for totals, subsequent for accounts
const embeds: DiscordEmbed[] = []
const headerEmbed: DiscordEmbed = {
title: '🎯 Microsoft Rewards Summary',
description: `Processed **${totalAccounts}** account(s)${accountsWithErrors ? `${accountsWithErrors} with issues` : ''}`,
color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
fields: baseFields,
const embed: DiscordEmbed = {
title: '🎯 Microsoft Rewards - Daily Summary',
description: [
'**📊 Global Statistics**',
`├ Total Points: **${totalInitial}** → **${totalEnd}** (+**${totalCollected}**)`,
`├ Accounts: ✅ ${successes}${accountsWithErrors > 0 ? `⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`,
`├ Average: **${avgPointsPerAccount}pts/account** • **${formatDuration(avgDuration)}/account**`,
`└ Runtime: **${formatDuration(totalDuration)}**`,
'',
'**📈 Account Details**',
...accountDetails
].filter(Boolean).join('\n'),
color: accountsWithErrors > 0 ? DISCORD.COLOR_ORANGE : DISCORD.COLOR_GREEN,
thumbnail: {
url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
},
timestamp: new Date().toISOString(),
footer: { text: `Run ${this.runId}${version !== 'unknown' ? ` • v${version}` : ''}` }
}
embeds.push(headerEmbed)
// Chunk account fields across remaining embeds
const fieldsPerEmbed = Math.min(MAX_FIELDS, 25)
const availableEmbeds = MAX_EMBEDS - embeds.length
const chunks: DiscordField[][] = []
for (let i = 0; i < accountFields.length; i += fieldsPerEmbed) {
chunks.push(accountFields.slice(i, i + fieldsPerEmbed))
}
const includedChunks = chunks.slice(0, availableEmbeds)
for (const [idx, chunk] of includedChunks.entries()) {
const chunkEmbed: DiscordEmbed = {
title: `Accounts ${idx * fieldsPerEmbed + 1}${Math.min((idx + 1) * fieldsPerEmbed, accountFields.length)}`,
color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
fields: chunk,
timestamp: new Date().toISOString()
}
embeds.push(chunkEmbed)
}
const omitted = chunks.length - includedChunks.length
if (omitted > 0 && embeds.length > 0) {
// Add a small note to the last embed about omitted accounts
const last = embeds[embeds.length - 1]!
const noteField: DiscordField = { name: 'Note', value: `And ${omitted * fieldsPerEmbed} more account entries not shown due to Discord limits.`, inline: false }
if (last.fields && Array.isArray(last.fields)) {
last.fields = [...last.fields, noteField].slice(0, MAX_FIELDS)
footer: {
text: `MS Rewards Bot v${version} • Run ${this.runId}`,
icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
}
}
// NTFY-compatible plain text (includes per-account breakdown)
// NTFY plain text fallback
const fallback = [
'Microsoft Rewards Summary',
`Accounts: ${totalAccounts}${accountsWithErrors ? `${accountsWithErrors} with issues` : ''}`,
`Total: ${totalInitial} -> ${totalEnd} (+${totalCollected})`,
`Average Duration: ${formatDuration(avgDuration)}`,
`Cumulative Runtime: ${formatDuration(totalDuration)}`,
'🎯 Microsoft Rewards Summary',
`Accounts: ${totalAccounts} (✅${successes} ${accountsWithErrors > 0 ? `⚠️${accountsWithErrors}` : ''})`,
`Total: ${totalInitial}${totalEnd} (+${totalCollected})`,
`Average: ${avgPointsPerAccount}pts/account • ${formatDuration(avgDuration)}`,
`Runtime: ${formatDuration(totalDuration)}`,
'',
...accountLines
...summaries.map(s => {
const st = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
return `${st} ${s.email}: +${s.totalCollected}pts (🖥️${s.desktopCollected} 📱${s.mobileCollected})`
})
].join('\n')
// Send both when any channel is enabled: Discord gets embeds, NTFY gets fallback
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhook(cfg, fallback, { embeds })
}
// Send webhook
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
}
// Write local JSON report for observability
// Write local JSON report
try {
const fs = await import('fs')
const path = await import('path')
@@ -1119,7 +1069,7 @@ export class MicrosoftRewardsBot {
log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
}
// Optionally cleanup old diagnostics folders
// Cleanup old diagnostics
try {
const days = cfg.diagnostics?.retentionDays
if (typeof days === 'number' && days > 0) {
@@ -1128,6 +1078,7 @@ export class MicrosoftRewardsBot {
} catch (e) {
log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn')
}
}
/** Reserve one diagnostics slot for this run (caps captures). */
@@ -1209,7 +1160,7 @@ export class MicrosoftRewardsBot {
{
title,
description: desc,
color: 0xFF0000
color: DISCORD.COLOR_RED
}
]
})
@@ -1252,8 +1203,6 @@ function formatDuration(ms: number): string {
}
async function main() {
// CommunityReporter disabled per project policy
// (previously: init + global hooks for uncaughtException/unhandledRejection)
const rewardsBot = new MicrosoftRewardsBot(false)
const crashState = { restarts: 0 }
@@ -1307,7 +1256,6 @@ async function main() {
if (require.main === module) {
main().catch(error => {
log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error')
// CommunityReporter disabled
process.exit(1)
})
}

View File

@@ -2,23 +2,20 @@ import axios from 'axios'
import { Config } from '../interface/Config'
import { Ntfy } from './Ntfy'
// Light obfuscation of the avatar URL (base64). Prevents casual editing in config.
const AVATAR_B64 = 'aHR0cHM6Ly9tZWRpYS5kaXNjb3JkYXBwLm5ldC9hdHRhY2htZW50cy8xNDIxMTYzOTUyOTcyMzY5OTMxLzE0MjExNjQxNDU5OTQyNDAxMTAvbXNuLnBuZz93aWR0aD01MTImZWlnaHQ9NTEy'
function getAvatarUrl(): string {
try { return Buffer.from(AVATAR_B64, 'base64').toString('utf-8') } catch { return '' }
}
// Avatar URL for webhook (new clean logo)
const AVATAR_URL = 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
type WebhookContext = 'summary' | 'ban' | 'security' | 'compromised' | 'spend' | 'error' | 'default'
function pickUsername(ctx: WebhookContext, fallbackColor?: number): string {
switch (ctx) {
case 'summary': return '📊 MS Rewards Summary'
case 'ban': return '🚫 Ban Alert'
case 'security': return '🔐 Security Alert'
case 'compromised': return '⚠️ Security Issue'
case 'spend': return '💳 Spend Notice'
case 'error': return ' Error Report'
default: return fallbackColor === 0xFF0000 ? ' Error Report' : '🎯 MS Rewards'
case 'summary': return 'MS Rewards - Daily Summary'
case 'ban': return 'MS Rewards - Ban Detected'
case 'security': return 'MS Rewards - Security Alert'
case 'compromised': return 'MS Rewards - Account Compromised'
case 'spend': return 'MS Rewards - Purchase Notification'
case 'error': return 'MS Rewards - Error Report'
default: return fallbackColor === 0xFF0000 ? 'MS Rewards - Error Report' : 'MS Rewards Bot'
}
}
@@ -55,7 +52,7 @@ export async function ConclusionWebhook(config: Config, content: string, payload
const firstColor = payload?.embeds && payload.embeds[0]?.color
const ctx: WebhookContext = payload?.context || (firstColor === 0xFF0000 ? 'error' : 'default')
body.username = pickUsername(ctx, firstColor)
body.avatar_url = getAvatarUrl()
body.avatar_url = AVATAR_URL
// Post to conclusion webhook if configured
const postWithRetry = async (url: string, label: string) => {

View File

@@ -69,8 +69,9 @@ function stripJsonComments(input: string): string {
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
function normalizeConfig(raw: unknown): Config {
// Using any here is necessary to support both legacy flat config and new nested config structures
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const n: any = (raw as any) || {}
const n = (raw || {}) as any
// Browser / execution
const headless = n.browser?.headless ?? n.headless ?? false

View File

@@ -3,6 +3,11 @@ import chalk from 'chalk'
import { Ntfy } from './Ntfy'
import { loadConfig } from './Load'
import { DISCORD } from '../constants'
// Avatar URL for webhook (consistent with ConclusionWebhook)
const WEBHOOK_AVATAR_URL = 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
const WEBHOOK_USERNAME = 'MS Rewards - Live Logs'
type WebhookBuffer = {
lines: string[]
@@ -30,20 +35,31 @@ async function sendBatch(url: string, buf: WebhookBuffer) {
while (buf.lines.length > 0) {
const next = buf.lines[0]!
const projected = currentLength + next.length + (chunk.length > 0 ? 1 : 0)
if (projected > 1900 && chunk.length > 0) break
if (projected > DISCORD.MAX_EMBED_LENGTH && chunk.length > 0) break
buf.lines.shift()
chunk.push(next)
currentLength = projected
}
const content = chunk.join('\n').slice(0, 1900)
const content = chunk.join('\n').slice(0, DISCORD.MAX_EMBED_LENGTH)
if (!content) {
continue
}
// Enhanced webhook payload with embed, username and avatar
const payload = {
username: WEBHOOK_USERNAME,
avatar_url: WEBHOOK_AVATAR_URL,
embeds: [{
description: `\`\`\`\n${content}\n\`\`\``,
color: determineColorFromContent(content),
timestamp: new Date().toISOString()
}]
}
try {
await axios.post(url, { content }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 })
await new Promise(resolve => setTimeout(resolve, 500))
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout: DISCORD.WEBHOOK_TIMEOUT })
await new Promise(resolve => setTimeout(resolve, DISCORD.RATE_LIMIT_DELAY))
} catch (error) {
// Re-queue failed batch at front and exit loop
buf.lines = chunk.concat(buf.lines)
@@ -54,6 +70,32 @@ async function sendBatch(url: string, buf: WebhookBuffer) {
buf.sending = false
}
function determineColorFromContent(content: string): number {
const lower = content.toLowerCase()
// Security/Ban alerts - Red
if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) {
return DISCORD.COLOR_RED
}
// Errors - Dark Red
if (lower.includes('[error]') || lower.includes('✗')) {
return DISCORD.COLOR_CRIMSON
}
// Warnings - Orange/Yellow
if (lower.includes('[warn]') || lower.includes('⚠')) {
return DISCORD.COLOR_ORANGE
}
// Success - Green
if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) {
return DISCORD.COLOR_GREEN
}
// Info/Main - Blue
if (lower.includes('[main]')) {
return DISCORD.COLOR_BLUE
}
// Default - Gray
return 0x95A5A6 // Gray
}
function enqueueWebhookLog(url: string, line: string) {
const buf = getBuffer(url)
buf.lines.push(line)
@@ -61,7 +103,7 @@ function enqueueWebhookLog(url: string, line: string) {
buf.timer = setTimeout(() => {
buf.timer = undefined
void sendBatch(url, buf)
}, 750)
}, DISCORD.DEBOUNCE_DELAY)
}
}

View File

@@ -20,14 +20,8 @@ export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPE
...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
}
const response = await axios.post(`${config.url}/${config.topic}`, message, { headers })
if (response.status === 200) {
console.log('NTFY notification successfully sent.')
} else {
console.error(`NTFY notification failed with status ${response.status}`)
}
await axios.post(`${config.url}/${config.topic}`, message, { headers })
} catch (error) {
console.error('Failed to send NTFY notification:', error)
// Silently fail - NTFY is a non-critical notification service
}
}

View File

@@ -43,7 +43,7 @@ export class QueryDiversityEngine {
const queries = await this.getFromSource(sourceName)
allQueries.push(...queries.slice(0, this.config.maxQueriesPerSource))
} catch (error) {
console.warn(`Failed to fetch from ${sourceName}:`, error instanceof Error ? error.message : error)
// Silently fail and try other sources
}
}
@@ -89,7 +89,8 @@ export class QueryDiversityEngine {
queries = this.getLocalFallback(20)
break
default:
console.warn(`Unknown source: ${source}`)
// Unknown source, skip silently
break
}
this.cache.set(source, {