mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-18 05:53:57 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
304
src/config.jsonc
304
src/config.jsonc
@@ -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 didn’t 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., 2–4 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
67
src/constants.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
192
src/index.ts
192
src/index.ts
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user