* 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

* feat: refactor ConclusionWebhook to improve structure and enhance message formatting

* feat: update setup scripts and version to 2.3.3, refactor paths for improved structure

* feat: refactor setup scripts to run via npm and improve error handling for package.json

* feat: refactor webhook avatar handling to use centralized constant from constants.ts

* feat: mettre à jour la version à 2.3.7 et améliorer le script de mise à jour avec des options de contrôle d'auto-mise à jour

* feat: activer la mise à jour automatique pour la configuration et les comptes

* feat: mettre à jour la version à 2.3.7 et améliorer la gestion des erreurs dans plusieurs fichiers

* feat: améliorer la gestion des erreurs et des délais dans plusieurs fichiers, y compris Axios et ConclusionWebhook

* feat: mettre à jour la version à 2.4.0 et améliorer la documentation sur le contrôle de mise à jour automatique

* feat: increase the number of passes per execution to 3 to improve task capture

* feat: update account management with new file format and filter disabled accounts

* feat: update version to 2.4.0, add reinstallation warning and support .jsonc extensions for configuration files

* fix: fix formatting of reinstallation message in README

* feat: add an important update notice in the README to recommend a complete reinstallation

* fix: remove backup instructions from installation guide in README

* fix: update notice in README for configuration file changes and fresh installation instructions

* fix: fix typographical error in README update notice

* Fix: Update avatar URL in Discord config and remove optional webhook properties

* exploit: add customization options for webhooks and improve notification format
This commit is contained in:
Light
2025-10-23 12:56:14 +02:00
committed by GitHub
parent abd6117db3
commit f2d00225c9
28 changed files with 1332 additions and 689 deletions

View File

@@ -1,31 +0,0 @@
{
"_note": "Microsoft allows up to 3 new accounts per IP per day; in practice it is recommended not to exceed around 5 active accounts per household IP to avoid looking suspicious; there is no official lifetime cap, but creating too many accounts quickly may trigger verification (phone, OTP, captcha); Microsoft discourages bulk account creation from the same IP; unusual activity can result in temporary blocks or account restrictions.",
"accounts": [
{
"email": "email_1",
"password": "password_1",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
"email": "email_2",
"password": "password_2",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
}
]
}

151
src/accounts.example.jsonc Normal file
View File

@@ -0,0 +1,151 @@
{
// ============================================================
// 📧 MICROSOFT ACCOUNTS CONFIGURATION
// ============================================================
// ⚠️ IMPORTANT SECURITY NOTICE
// This file contains sensitive credentials. Never commit the real accounts.jsonc to version control.
// The .gitignore is configured to exclude accounts.jsonc but you should verify it's not tracked.
// 📊 MICROSOFT ACCOUNT LIMITS (Unofficial Guidelines)
// - New accounts per IP per day: ~3 (official soft limit)
// - Recommended active accounts per household IP: ~5 (to avoid suspicion)
// - Creating too many accounts quickly may trigger verification (phone, OTP, captcha)
// - Unusual activity can result in temporary blocks or account restrictions
"accounts": [
{
// ============================================================
// 👤 ACCOUNT 1
// ============================================================
// Enable or disable this account (true = active, false = skip)
"enabled": true,
// Microsoft account email address
"email": "email_1@outlook.com",
// Account password
"password": "password_1",
// Two-Factor Authentication (2FA) TOTP secret (optional but HIGHLY recommended for security)
// Get this from your authenticator app (e.g., Microsoft Authenticator, Google Authenticator)
// Format: base32 secret key (e.g., "JBSWY3DPEHPK3PXP")
// Leave empty "" if 2FA is not enabled
"totp": "",
// ⚠️ REQUIRED: Recovery email address associated with this Microsoft account
// During login, Microsoft shows the first 2 characters and the domain of the recovery email (e.g., "ab***@example.com")
// This field is MANDATORY to detect account compromise or bans:
// - The script compares what Microsoft displays with this configured recovery email
// - If they don't match, it alerts you that the account may be compromised or the recovery email was changed
// - This security check helps identify hijacked accounts before they cause issues
// Format: Full recovery email address (e.g., "backup@gmail.com")
"recoveryEmail": "your_email@domain.com",
// ============================================================
// 🌐 PROXY CONFIGURATION (Optional)
// ============================================================
"proxy": {
// Enable proxy for HTTP requests (axios/API calls)
// If false, proxy is only used for browser automation
"proxyAxios": true,
// Proxy server URL (without protocol)
// Examples: "proxy.example.com", "123.45.67.89"
// Leave empty "" to disable proxy for this account
"url": "",
// Proxy port number
"port": 0,
// Proxy authentication username (leave empty if no auth required)
"username": "",
// Proxy authentication password (leave empty if no auth required)
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 2
// ============================================================
"enabled": false,
"email": "email_2@outlook.com",
"password": "password_2",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 3
// ============================================================
"enabled": false,
"email": "email_3@outlook.com",
"password": "password_3",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 4
// ============================================================
"enabled": false,
"email": "email_4@outlook.com",
"password": "password_4",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
},
{
// ============================================================
// 👤 ACCOUNT 5
// ============================================================
"enabled": false,
"email": "email_5@outlook.com",
"password": "password_5",
"totp": "",
"recoveryEmail": "your_email@domain.com",
"proxy": {
"proxyAxios": true,
"url": "",
"port": 0,
"username": "",
"password": ""
}
}
]
}

View File

@@ -54,7 +54,8 @@ class Browser {
const engineName = 'chromium' // current hard-coded engine
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
browser = await playwright.chromium.launch({
//channel: 'msedge', // Uses Edge instead of chrome
// Optional: uncomment to use Edge instead of Chromium
// channel: 'msedge',
headless,
...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }),
args: [
@@ -70,7 +71,7 @@ class Browser {
const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run: "npx playwright install chromium" (or set AUTO_INSTALL_BROWSERS=1 to auto attempt).', 'error')
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error')
} else {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
}

View File

@@ -45,8 +45,9 @@
"runOnZeroPoints": false,
// Number of account clusters (processes) to run concurrently
"clusters": 1,
// Number of passes per invocation (usually 1)
"passesPerRun": 1
// How many times to run through all accounts in sequence (1 = process each account once, 2 = twice, etc.)
// Higher values can catch missed tasks but increase detection risk
"passesPerRun": 3
},
"schedule": {
@@ -207,12 +208,18 @@
// Live logs webhook (Discord or similar). URL = your webhook endpoint
"webhook": {
"enabled": false,
"url": ""
"url": "",
// Optional: Customize webhook appearance
"username": "Live Logs",
"avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png"
},
// Rich end-of-run summary webhook (Discord or similar)
"conclusionWebhook": {
"enabled": false,
"url": ""
"url": "",
// Optional: Customize webhook appearance (overrides webhook settings for conclusion messages)
"username": "Microsoft Rewards",
"avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png"
},
// NTFY push notifications (plain text)
"ntfy": {
@@ -293,6 +300,44 @@
"git": true,
"docker": false,
// Custom updater script path (relative to repo root)
"scriptPath": "setup/update/update.mjs"
"scriptPath": "setup/update/update.mjs",
// ⚠️ SMART UPDATE CONTROL - How It Really Works:
//
// BACKUP: Your files are ALWAYS backed up to .update-backup/ before any update
//
// UPDATE PROCESS:
// 1. Script checks if remote modified config.jsonc or accounts.json
// 2. Runs "git pull --rebase" to merge remote changes
// 3. Git intelligently merges:
// ✅ NEW FIELDS ADDED (new config options, new account properties)
// → Your existing values are PRESERVED, new fields are added alongside
// → This is 95% of updates - works perfectly without conflicts
//
// ⚠️ MAJOR RESTRUCTURING (fields renamed, sections reordered, format changed)
// → Git may choose one version over the other
// → Risk of losing your custom values in restructured sections
//
// WHAT THE OPTIONS DO:
// - true: ACCEPT git merge result (keeps new features + your settings in most cases)
// - false: REJECT remote changes, RESTORE your local file from backup (stay on old version)
//
// RECOMMENDED: Keep both TRUE
// Why? Because we rarely restructure files. Most updates just ADD new optional fields.
// Your passwords, emails, and custom settings survive addition-only updates.
// Only risk: major file restructuring (rare, usually announced in release notes).
//
// SAFETY NET: Check .update-backup/ folder after updates to compare if worried.
// Apply remote updates to config.jsonc via git merge
// true = accept new features + intelligent merge (RECOMMENDED for most users)
// false = always keep your local version (miss new config options)
"autoUpdateConfig": true,
// Apply remote updates to accounts.json via git merge
// true = accept new fields (like "region", "totpSecret") while keeping credentials (RECOMMENDED)
// false = always keep your local accounts file (safest but may miss new optional fields)
"autoUpdateAccounts": true
}
}

View File

@@ -63,5 +63,12 @@ export const DISCORD = {
COLOR_CRIMSON: 0xDC143C,
COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB,
COLOR_GREEN: 0x00D26A
COLOR_GREEN: 0x00D26A,
AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
} as const
export const META = {
C: 'aHR0cHM6Ly9kaXNjb3JkLmdnL2tuMzY5NUt4MzI=',
R: 'aHR0cHM6Ly9naXRodWIuY29tL0xpZ2h0NjAtMS9NaWNyb3NvZnQtUmV3YXJkcy1SZXdp'
} as const

View File

@@ -28,7 +28,14 @@ const SELECTORS = {
const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
const DEFAULT_TIMEOUTS = {
loginMaxMs: Number(process.env.LOGIN_MAX_WAIT_MS || 180000), // 3 min
loginMaxMs: (() => {
const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000)
if (isNaN(val) || val < 10000 || val > 600000) {
console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 180000ms`)
return 180000
}
return val
})(),
short: 500,
medium: 1500,
long: 3000
@@ -71,6 +78,12 @@ export class Login {
// --------------- Public API ---------------
async login(page: Page, email: string, password: string, totpSecret?: string) {
try {
// Clear any existing intervals from previous runs
if (this.compromisedInterval) {
clearInterval(this.compromisedInterval)
this.compromisedInterval = undefined
}
this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
@@ -285,40 +298,45 @@ export class Login {
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')
try {
const inputPromise = new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (checkInterval) clearInterval(checkInterval)
rl.close()
userInput = 'skip' // Signal to skip submission
}
} catch {/* ignore */}
}, 2000)
res(ans.trim())
})
})
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
// 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 (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')
} finally {
// Ensure cleanup happens even if errors occur
if (checkInterval) clearInterval(checkInterval)
try { rl.close() } catch {/* ignore */}
}
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
}
private async ensureTotpInput(page: Page): Promise<string | null> {
@@ -758,12 +776,19 @@ export class Login {
this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level)
try {
const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
await ConclusionWebhook(this.bot.config,'', { embeds:[{ title:`🔐 ${incident.kind}`, description:'Security check by @Light', color: severity==='critical'?0xFF0000:0xFFAA00, fields:[
{ name:'Account', value: incident.account },
...(incident.details?.length?[{ name:'Details', value: incident.details.join('\n') }]:[]),
...(incident.next?.length?[{ name:'Next steps', value: incident.next.join('\n') }]:[]),
...(incident.docsUrl?[{ name:'Docs', value: incident.docsUrl }]:[])
] }] })
const fields = [
{ name: 'Account', value: incident.account },
...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []),
...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []),
...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : [])
]
await ConclusionWebhook(
this.bot.config,
`🔐 ${incident.kind}`,
'_Security check by @Light_',
fields,
severity === 'critical' ? 0xFF0000 : 0xFFAA00
)
} catch {/* ignore */}
}

View File

@@ -43,6 +43,9 @@ export class MicrosoftRewardsBot {
public compromisedModeActive: boolean = false
public compromisedReason?: string
public compromisedEmail?: string
// Mutex-like flag to prevent parallel execution when config.parallel is accidentally misconfigured
private isDesktopRunning: boolean = false
private isMobileRunning: boolean = false
private pointsCanCollect: number = 0
private pointsInitial: number = 0
@@ -185,24 +188,13 @@ export class MicrosoftRewardsBot {
const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => {
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const title = '💳 Spend detected (Buy Mode)'
const desc = [
`Account: ${account.email}`,
`Spent: -${delta} points`,
`Current: ${nowPts} points`,
`Session spent: ${cumulativeSpent} points`
].join('\n')
await ConclusionWebhook(this.config, '', {
context: 'spend',
embeds: [
{
title,
description: desc,
// Use warn color so NTFY is sent as warn
color: 0xFFAA00
}
]
})
await ConclusionWebhook(
this.config,
'💳 Spend Detected',
`**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`,
undefined,
0xFFAA00
)
} catch (e) {
this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn')
}
@@ -261,7 +253,11 @@ export class MicrosoftRewardsBot {
}
// Save cookies and close monitor; keep main page open for user until they close it themselves
try { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch { /* ignore */ }
try {
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
} catch (e) {
log(false, 'BUY-MODE', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
try { if (!monitor.isClosed()) await monitor.close() } catch {/* ignore */}
// Send a final minimal conclusion webhook for this manual session
@@ -318,19 +314,23 @@ export class MicrosoftRewardsBot {
╚══════════════════════════════════════════════════════╝
`
// Read package version and build banner info
const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown'
try {
const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown'
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(raw)
version = pkg.version || version
}
// Show appropriate banner based on mode
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner)
console.log('='.repeat(80))
} catch {
// Ignore version read errors
}
// Display appropriate banner based on mode
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner)
console.log('='.repeat(80))
if (this.buyMode.enabled) {
console.log(` Version: ${version} | Process: ${process.pid} | Buy Mode: Active`)
@@ -376,19 +376,9 @@ export class MicrosoftRewardsBot {
}
}
console.log('='.repeat(80) + '\n')
} catch {
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner)
console.log('='.repeat(50))
if (this.buyMode.enabled) {
console.log(' Microsoft Rewards Buy Mode Started')
console.log(' See buy-mode.md for details')
} else {
console.log(' Microsoft Rewards Script Started')
}
console.log('='.repeat(50) + '\n')
}
} // Return summaries (used when clusters==1)
}
// Return summaries (used when clusters==1)
public getSummaries() {
return this.accountSummaries
}
@@ -397,8 +387,15 @@ export class MicrosoftRewardsBot {
log('main', 'MAIN-PRIMARY', 'Primary process started')
const totalAccounts = this.accounts.length
// Validate accounts exist
if (totalAccounts === 0) {
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn')
process.exit(0)
}
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
const workerCount = Math.min(this.config.clusters, totalAccounts || 1)
const workerCount = Math.min(this.config.clusters, totalAccounts)
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount
@@ -406,7 +403,13 @@ export class MicrosoftRewardsBot {
for (let i = 0; i < workerCount; i++) {
const worker = cluster.fork()
const chunk = accountChunks[i] || []
;(worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk })
// Validate chunk has accounts
if (chunk.length === 0) {
log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn')
}
(worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk })
worker.on('message', (msg: unknown) => {
const m = msg as { type?: string; data?: AccountSummary[] }
if (m && m.type === 'summary' && Array.isArray(m.data)) {
@@ -448,8 +451,13 @@ export class MicrosoftRewardsBot {
try {
await this.runAutoUpdate()
} catch {/* ignore */}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
} else {
log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.')
}
})()
}
})
@@ -536,52 +544,72 @@ export class MicrosoftRewardsBot {
}
errors.push(formatFullErr('mobile', e)); return null
})
const [desktopResult, mobileResult] = await Promise.all([desktopPromise, mobilePromise])
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
// Handle desktop result
if (desktopResult.status === 'fulfilled' && desktopResult.value) {
desktopInitial = desktopResult.value.initialPoints
desktopCollected = desktopResult.value.collectedPoints
} else if (desktopResult.status === 'rejected') {
log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error')
errors.push(formatFullErr('desktop-rejected', desktopResult.reason))
}
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
// Handle mobile result
if (mobileResult.status === 'fulfilled' && mobileResult.value) {
mobileInitial = mobileResult.value.initialPoints
mobileCollected = mobileResult.value.collectedPoints
} else if (mobileResult.status === 'rejected') {
log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error')
errors.push(formatFullErr('mobile-rejected', mobileResult.reason))
}
} else {
this.isMobile = false
const desktopResult = await this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('desktop', e)); return null
})
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
}
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
const mobileResult = await this.Mobile(account).catch(e => {
// Sequential execution with safety checks
if (this.isDesktopRunning || this.isMobileRunning) {
log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error')
errors.push('race-condition-detected')
} else {
this.isMobile = false
this.isDesktopRunning = true
const desktopResult = await this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
errors.push(formatFullErr('desktop', e)); return null
})
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
}
this.isDesktopRunning = false
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
this.isMobileRunning = true
const mobileResult = await this.Mobile(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
})
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
}
this.isMobileRunning = false
} else {
const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
}
} else {
const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
}
}
@@ -633,10 +661,14 @@ export class MicrosoftRewardsBot {
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
if (this.compromisedModeActive || this.globalStandby.active) {
log('main','SECURITY','Compromised or banned detected. Global standby engaged: we will NOT proceed to other accounts until resolved. Keeping process alive. Press CTRL+C to exit when done. Security check by @Light','warn','yellow')
// Periodic heartbeat
setInterval(() => {
// Periodic heartbeat with cleanup on exit
const standbyInterval = setInterval(() => {
log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow')
}, 5 * 60 * 1000)
// Cleanup on process exit
process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) })
process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) })
return
}
// If in worker mode (clusters>1) send summaries to primary
@@ -650,10 +682,8 @@ 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 (only if not in scheduler mode)
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
// After conclusion, run optional auto-update
await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
@@ -667,17 +697,13 @@ export class MicrosoftRewardsBot {
const h = this.config?.humanization
if (!h || h.immediateBanAlert === false) return
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const title = '🚫 Ban detected'
const desc = [`Account: ${email}`, `Reason: ${reason || 'detected by heuristics'}`].join('\n')
await ConclusionWebhook(this.config, `${title}\n${desc}`, {
embeds: [
{
title,
description: desc,
color: DISCORD.COLOR_RED
}
]
})
await ConclusionWebhook(
this.config,
'🚫 Ban Detected',
`**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`,
undefined,
DISCORD.COLOR_RED
)
} catch (e) {
log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn')
}
@@ -736,19 +762,20 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}. Security check by @Light`, 'warn', 'yellow')
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Logged in successfully; leaving browser open. Security check by @Light`, {
context: 'compromised',
embeds: [
{
title: '🔐 Security alert (post-login)',
description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving browser open; skipping tasks`,
color: 0xFFAA00
}
]
})
await ConclusionWebhook(
this.config,
'🔐 Security Alert (Post-Login)',
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks\n\n_Security check by @Light_`,
undefined,
0xFFAA00
)
} catch {/* ignore */}
// Save session for convenience, but do not close the browser
try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 }
}
@@ -839,18 +866,19 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}. Security check by @Light`, 'warn', 'yellow')
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Mobile flow halted; leaving browser open. Security check by @Light`, {
context: 'compromised',
embeds: [
{
title: '🔐 Security alert (mobile)',
description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving mobile browser open; skipping tasks`,
color: 0xFFAA00
}
]
})
await ConclusionWebhook(
this.config,
'🔐 Security Alert (Mobile)',
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks\n\n_Security check by @Light_`,
undefined,
0xFFAA00
)
} catch {/* ignore */}
try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 }
}
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
@@ -944,7 +972,7 @@ export class MicrosoftRewardsBot {
}
private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const { ConclusionWebhookEnhanced } = await import('./util/ConclusionWebhook')
const cfg = this.config
const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
@@ -959,6 +987,7 @@ export class MicrosoftRewardsBot {
let totalEnd = 0
let totalDuration = 0
let accountsWithErrors = 0
let accountsBanned = 0
let successes = 0
// Calculate summary statistics
@@ -967,8 +996,9 @@ export class MicrosoftRewardsBot {
totalInitial += s.initialTotal
totalEnd += s.endTotal
totalDuration += s.durationMs
if (s.banned?.status) accountsBanned++
if (s.errors.length) accountsWithErrors++
else successes++
if (!s.banned?.status && !s.errors.length) successes++
}
const avgDuration = totalDuration / totalAccounts
@@ -985,67 +1015,23 @@ export class MicrosoftRewardsBot {
}
} catch { /* ignore */ }
// 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 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(', ')}`)
}
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: `MS Rewards Bot v${version} • Run ${this.runId}`,
icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
}
}
// NTFY plain text fallback
const fallback = [
'🎯 Microsoft Rewards Summary',
`Accounts: ${totalAccounts} (✅${successes} ${accountsWithErrors > 0 ? `⚠️${accountsWithErrors}` : ''})`,
`Total: ${totalInitial}${totalEnd} (+${totalCollected})`,
`Average: ${avgPointsPerAccount}pts/account • ${formatDuration(avgDuration)}`,
`Runtime: ${formatDuration(totalDuration)}`,
'',
...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 webhook
// Send enhanced webhook
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
await ConclusionWebhookEnhanced(cfg, {
version,
runId: this.runId,
totalAccounts,
successes,
accountsWithErrors,
accountsBanned,
totalCollected,
totalInitial,
totalEnd,
avgPointsPerAccount,
totalDuration,
avgDuration,
summaries
})
}
// Write local JSON report
@@ -1079,6 +1065,11 @@ export class MicrosoftRewardsBot {
log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn')
}
// Optional community notice (shown randomly in ~15% of successful runs)
if (Math.random() > 0.85 && successes > 0 && accountsWithErrors === 0) {
log('main','INFO','Want faster updates & enhanced anti-detection? Community builds available: https://discord.gg/kn3695Kx32')
}
}
/** Reserve one diagnostics slot for this run (caps captures). */
@@ -1125,8 +1116,14 @@ export class MicrosoftRewardsBot {
if (upd.docker) args.push('--docker')
if (args.length === 0) return
// Pass scheduler flag to update script so it doesn't exit
const isSchedulerMode = !!process.env.SCHEDULER_HEARTBEAT_FILE
const env = isSchedulerMode
? { ...process.env, FROM_SCHEDULER: '1' }
: process.env
await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' })
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit', env })
child.on('close', () => resolve())
child.on('error', () => resolve())
})
@@ -1146,24 +1143,13 @@ export class MicrosoftRewardsBot {
private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> {
try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const title = '🚨 Global security standby engaged'
const desc = [
`Account: ${email}`,
`Reason: ${reason}`,
'Action: Pausing all further accounts. We will not proceed until this is resolved.',
'Security check by @Light'
].join('\n')
// Mention everyone in content for Discord visibility
const content = '@everyone ' + title
await ConclusionWebhook(this.config, content, {
embeds: [
{
title,
description: desc,
color: DISCORD.COLOR_RED
}
]
})
await ConclusionWebhook(
this.config,
'🚨 Global Security Standby Engaged',
`@everyone\n\n**Account:** ${email}\n**Reason:** ${reason}\n**Action:** Pausing all further accounts. We will not proceed until this is resolved.\n\n_Security check by @Light_`,
undefined,
DISCORD.COLOR_RED
)
} catch (e) {
log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn')
}

View File

@@ -1,4 +1,6 @@
export interface Account {
/** Enable/disable this account (if false, account will be skipped during execution) */
enabled?: boolean;
email: string;
password: string;
/** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */

View File

@@ -67,8 +67,8 @@ export interface ConfigSearchDelay {
export interface ConfigWebhook {
enabled: boolean;
url: string;
username?: string; // Optional override for displayed webhook name
avatarUrl?: string; // Optional avatar image URL
username?: string; // Custom webhook username (default: "Microsoft Rewards")
avatarUrl?: string; // Custom webhook avatar URL
}
export interface ConfigNtfy {
@@ -95,6 +95,8 @@ export interface ConfigUpdate {
git?: boolean; // if true, run git pull + npm ci + npm run build after completion
docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
scriptPath?: string; // optional custom path to update script relative to repo root
autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings)
autoUpdateAccounts?: boolean; // if true, allow auto-update of accounts.json when remote changes it (default: false to preserve credentials)
}
export interface ConfigBuyMode {

View File

@@ -13,6 +13,12 @@ type DateTimeInstance = ReturnType<typeof DateTime.fromJSDate>
function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } {
const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
// Warn if an invalid timezone was provided
if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) {
void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn')
}
// Determine source string
let src = ''
if (typeof schedule?.useAmPm === 'boolean') {
@@ -114,13 +120,28 @@ async function runOnePassWithWatchdog(): Promise<void> {
// Heartbeat-aware watchdog configuration
// If a child is actively updating its heartbeat file, we allow it to run beyond the legacy timeout.
// Defaults are generous to allow first-day passes to finish searches with delays.
const staleHeartbeatMin = Number(
process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES || process.env.SCHEDULER_PASS_TIMEOUT_MINUTES || 30
const parseEnvNumber = (key: string, fallback: number, min: number, max: number): number => {
const val = Number(process.env[key] || fallback)
if (isNaN(val) || val < min || val > max) {
void log('main', 'SCHEDULER', `Invalid ${key}="${process.env[key]}". Using default ${fallback}`, 'warn')
return fallback
}
return val
}
const staleHeartbeatMin = parseEnvNumber(
process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES ? 'SCHEDULER_STALE_HEARTBEAT_MINUTES' : 'SCHEDULER_PASS_TIMEOUT_MINUTES',
30, 5, 1440
)
const graceMin = Number(process.env.SCHEDULER_HEARTBEAT_GRACE_MINUTES || 15)
const hardcapMin = Number(process.env.SCHEDULER_PASS_HARDCAP_MINUTES || 480) // 8 hours
const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120)
const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440)
const checkEveryMs = 60_000 // check once per minute
// Validate: stale should be >= grace
if (staleHeartbeatMin < graceMin) {
await log('main', 'SCHEDULER', `Warning: STALE_HEARTBEAT (${staleHeartbeatMin}m) < GRACE (${graceMin}m). Adjusting stale to ${graceMin}m`, 'warn')
}
// Fork per pass: safer because we can terminate a stuck child without killing the scheduler
const forkPerPass = String(process.env.SCHEDULER_FORK_PER_PASS || 'true').toLowerCase() !== 'false'
@@ -147,6 +168,8 @@ async function runOnePassWithWatchdog(): Promise<void> {
let finished = false
const startedAt = Date.now()
let killTimeout: NodeJS.Timeout | undefined
const killChild = async (signal: NodeJS.Signals) => {
try {
await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn')
@@ -162,7 +185,8 @@ async function runOnePassWithWatchdog(): Promise<void> {
if (runtimeMin >= hardcapMin) {
log('main', 'SCHEDULER', `Pass exceeded hard cap of ${hardcapMin} minutes; terminating...`, 'warn')
void killChild('SIGTERM')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
return
}
// Before grace, don't judge
@@ -175,19 +199,23 @@ async function runOnePassWithWatchdog(): Promise<void> {
if (ageMin >= staleHeartbeatMin) {
log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${staleHeartbeatMin}m). Terminating child...`, 'warn')
void killChild('SIGTERM')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
} catch {
} catch (err) {
// If file missing after grace, consider stale
log('main', 'SCHEDULER', 'Heartbeat file missing after grace. Terminating child...', 'warn')
const msg = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn')
void killChild('SIGTERM')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
}
}, checkEveryMs)
child.on('exit', async (code, signal) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
// Cleanup heartbeat file
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
if (signal) {
@@ -203,6 +231,7 @@ async function runOnePassWithWatchdog(): Promise<void> {
child.on('error', async (err) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error')
resolve()
@@ -286,9 +315,21 @@ async function main() {
let running = false
// Optional initial jitter before the first run (to vary start time)
const initJitterMin = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MIN || process.env.SCHEDULER_INITIAL_JITTER_MIN || 0)
const initJitterMax = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MAX || process.env.SCHEDULER_INITIAL_JITTER_MAX || 0)
const initialJitterBounds: [number, number] = [isFinite(initJitterMin) ? initJitterMin : 0, isFinite(initJitterMax) ? initJitterMax : 0]
const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => {
const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0)
const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0)
if (isNaN(minVal) || minVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn')
return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal]
}
if (isNaN(maxVal) || maxVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn')
return [minVal, 0]
}
return [minVal, maxVal]
}
const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX')
const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0)
if (runImmediate && !running) {
@@ -327,10 +368,9 @@ async function main() {
// Optional daily jitter to further randomize the exact start time each day
let extraMs = 0
if (cronExpressions.length === 0) {
const dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0)
const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0)
const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0
const djMax = isFinite(dailyJitterMax) ? dailyJitterMax : 0
const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX')
const djMin = dailyJitterBounds[0]
const djMax = dailyJitterBounds[1]
if (djMin > 0 || djMax > 0) {
const mn = Math.max(0, Math.min(djMin, djMax))
const mx = Math.max(0, Math.max(djMin, djMax))
@@ -373,6 +413,6 @@ async function main() {
}
main().catch((e) => {
console.error(e)
void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error')
process.exit(1)
})

View File

@@ -202,6 +202,11 @@ export class Analytics {
)
}
// Optional community info footer (only in markdown exports)
lines.push('')
lines.push('---')
lines.push('*Looking for faster updates, advanced features, and better anti-detection? Join our community for enhanced versions and support.*')
return lines.join('\n')
}

View File

@@ -24,14 +24,14 @@ class AxiosClient {
const { url, port } = proxyConfig
switch (true) {
case proxyConfig.url.startsWith('http'):
case proxyConfig.url.startsWith('http://'):
return new HttpProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('https'):
case proxyConfig.url.startsWith('https://'):
return new HttpsProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('socks'):
case proxyConfig.url.startsWith('socks://') || proxyConfig.url.startsWith('socks4://') || proxyConfig.url.startsWith('socks5://'):
return new SocksProxyAgent(`${url}:${port}`)
default:
throw new Error(`Unsupported proxy protocol: ${url}`)
throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`)
}
}
@@ -42,29 +42,54 @@ class AxiosClient {
return bypassInstance.request(config)
}
try {
return await this.instance.request(config)
} catch (err: unknown) {
const axiosErr = err as AxiosError | undefined
let lastError: unknown
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.instance.request(config)
} catch (err: unknown) {
lastError = err
const axiosErr = err as AxiosError | undefined
// Detect HTTP proxy auth failures (status 407) and retry without proxy once.
if (!bypassProxy && axiosErr && axiosErr.response && axiosErr.response.status === 407) {
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// Detect HTTP proxy auth failures (status 407) and retry without proxy
if (axiosErr && axiosErr.response && axiosErr.response.status === 407) {
if (attempt < maxAttempts) {
await this.sleep(1000 * attempt) // Exponential backoff
}
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// If proxied request fails with common proxy/network errors, retry once without proxy
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
const code = e?.code || e?.cause?.code
const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
const msg = String(e?.message || '')
const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
if (!bypassProxy && (isNetErr || looksLikeProxyIssue)) {
const bypassInstance = axios.create()
return bypassInstance.request(config)
// If proxied request fails with common proxy/network errors, retry with backoff
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
const code = e?.code || e?.cause?.code
const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
const msg = String(e?.message || '')
const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
if (isNetErr || looksLikeProxyIssue) {
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s, etc.
const delayMs = 1000 * Math.pow(2, attempt - 1)
await this.sleep(delayMs)
continue
}
// Last attempt: try without proxy
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// Non-retryable error
throw err
}
throw err
}
throw lastError
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

View File

@@ -1,106 +1,358 @@
import axios from 'axios'
import { Config } from '../interface/Config'
import { Ntfy } from './Ntfy'
import { DISCORD } from '../constants'
import { log } from './Logger'
// 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 - 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'
}
interface DiscordField {
name: string
value: string
inline?: boolean
}
interface DiscordField { name: string; value: string; inline?: boolean }
interface DiscordEmbed {
title?: string
description?: string
color?: number
fields?: DiscordField[]
timestamp?: string
footer?: {
text: string
icon_url?: string
}
thumbnail?: {
url: string
}
author?: {
name: string
icon_url?: string
}
}
interface ConclusionPayload {
content?: string
embeds?: DiscordEmbed[]
context?: WebhookContext
interface WebhookPayload {
username: string
avatar_url: string
embeds: DiscordEmbed[]
}
interface AccountSummary {
email: string
totalCollected: number
desktopCollected: number
mobileCollected: number
initialTotal: number
endTotal: number
durationMs: number
errors: string[]
banned?: { status: boolean; reason?: string }
}
interface ConclusionData {
version: string
runId: string
totalAccounts: number
successes: number
accountsWithErrors: number
accountsBanned: number
totalCollected: number
totalInitial: number
totalEnd: number
avgPointsPerAccount: number
totalDuration: number
avgDuration: number
summaries: AccountSummary[]
}
/**
* Send a final structured summary to the configured webhook,
* and optionally mirror a plain-text summary to NTFY.
*
* This preserves existing webhook behavior while adding NTFY
* as a separate, optional channel.
* Send a clean, structured Discord webhook notification
*/
export async function ConclusionWebhook(config: Config, content: string, payload?: ConclusionPayload) {
// Send to both webhooks when available
const hasConclusion = !!(config.conclusionWebhook?.enabled && config.conclusionWebhook.url)
const hasWebhook = !!(config.webhook?.enabled && config.webhook.url)
const sameTarget = hasConclusion && hasWebhook && config.conclusionWebhook!.url === config.webhook!.url
export async function ConclusionWebhook(
config: Config,
title: string,
description: string,
fields?: DiscordField[],
color?: number
) {
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
const hasWebhook = config.webhook?.enabled && config.webhook.url
const body: ConclusionPayload & { username?: string; avatar_url?: string } = {}
if (payload?.embeds) body.embeds = payload.embeds
if (content && content.trim()) body.content = content
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 = AVATAR_URL
if (!hasConclusion && !hasWebhook) return
// Post to conclusion webhook if configured
const postWithRetry = async (url: string, label: string) => {
const max = 2
let lastErr: unknown = null
for (let attempt = 1; attempt <= max; attempt++) {
const embed: DiscordEmbed = {
title,
description,
color: color || 0x0078D4,
timestamp: new Date().toISOString()
}
if (fields && fields.length > 0) {
embed.fields = fields
}
// Use custom webhook settings if provided, otherwise fall back to defaults
const webhookUsername = config.webhook?.username || config.conclusionWebhook?.username || 'Microsoft Rewards'
const webhookAvatarUrl = config.webhook?.avatarUrl || config.conclusionWebhook?.avatarUrl || DISCORD.AVATAR_URL
const payload: WebhookPayload = {
username: webhookUsername,
avatar_url: webhookAvatarUrl,
embeds: [embed]
}
const postWebhook = async (url: string, label: string) => {
const maxAttempts = 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.post(url, body, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 })
console.log(`[Webhook:${label}] summary sent (attempt ${attempt}).`)
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000
})
log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`)
return
} catch (e) {
lastErr = e
if (attempt === max) break
await new Promise(r => setTimeout(r, 1000 * attempt))
} catch (error) {
lastError = error
if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s
const delayMs = 1000 * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
console.error(`[Webhook:${label}] failed after ${max} attempts:`, lastErr)
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
}
if (hasConclusion) {
await postWithRetry(config.conclusionWebhook!.url, sameTarget ? 'conclusion+primary' : 'conclusion')
}
if (hasWebhook && !sameTarget) {
await postWithRetry(config.webhook!.url, 'primary')
}
const urls = new Set<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
// NTFY: mirror a plain text summary (optional)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`))
)
// Optional NTFY notification
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
let message = content || ''
if (!message && payload?.embeds && payload.embeds.length > 0) {
const e: DiscordEmbed = payload.embeds[0]!
const title = e.title ? `${e.title}\n` : ''
const desc = e.description ? `${e.description}\n` : ''
const totals = e.fields && e.fields[0]?.value ? `\n${e.fields[0].value}\n` : ''
message = `${title}${desc}${totals}`.trim()
}
if (!message) message = 'Microsoft Rewards run complete.'
// Choose NTFY level based on embed color (yellow = warn)
let embedColor: number | undefined
if (payload?.embeds && payload.embeds.length > 0) {
embedColor = payload.embeds[0]!.color
}
const ntfyType = embedColor === 0xFFAA00 ? 'warn' : 'log'
const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}`
const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
console.log('Conclusion summary sent to NTFY.')
} catch (err) {
console.error('Failed to send conclusion summary to NTFY:', err)
log('main', 'NTFY', 'Notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
}
}
/**
* Enhanced conclusion webhook with beautiful formatting and clear statistics
*/
export async function ConclusionWebhookEnhanced(config: Config, data: ConclusionData) {
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
const hasWebhook = config.webhook?.enabled && config.webhook.url
if (!hasConclusion && !hasWebhook) return
// Helper to format duration
const formatDuration = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
if (minutes > 0) return `${minutes}m ${seconds}s`
return `${seconds}s`
}
// Helper to create progress bar (future use)
// const createProgressBar = (current: number, max: number, length: number = 10): string => {
// const percentage = Math.min(100, Math.max(0, (current / max) * 100))
// const filled = Math.round((percentage / 100) * length)
// const empty = length - filled
// return `${'█'.repeat(filled)}${'░'.repeat(empty)} ${percentage.toFixed(0)}%`
// }
// Determine overall status and color
let statusEmoji = '✅'
let statusText = 'Success'
let embedColor: number = DISCORD.COLOR_GREEN
if (data.accountsBanned > 0) {
statusEmoji = '🚫'
statusText = 'Banned Accounts Detected'
embedColor = DISCORD.COLOR_RED
} else if (data.accountsWithErrors > 0) {
statusEmoji = '⚠️'
statusText = 'Completed with Warnings'
embedColor = DISCORD.COLOR_ORANGE
}
// Build main summary description
const mainDescription = [
`**Status:** ${statusEmoji} ${statusText}`,
`**Version:** v${data.version} • **Run ID:** \`${data.runId}\``,
'',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
].join('\n')
// Build global statistics field
const globalStats = [
`**💎 Total Points Earned**`,
`\`${data.totalInitial.toLocaleString()}\`\`${data.totalEnd.toLocaleString()}\` **(+${data.totalCollected.toLocaleString()})**`,
'',
`**📊 Accounts Processed**`,
`✅ Success: **${data.successes}** | ⚠️ Errors: **${data.accountsWithErrors}** | 🚫 Banned: **${data.accountsBanned}**`,
`Total: **${data.totalAccounts}** ${data.totalAccounts === 1 ? 'account' : 'accounts'}`,
'',
`**⚡ Performance**`,
`Average: **${data.avgPointsPerAccount}pts/account** in **${formatDuration(data.avgDuration)}**`,
`Total Runtime: **${formatDuration(data.totalDuration)}**`
].join('\n')
// Build per-account breakdown (split if too many accounts)
const accountFields: DiscordField[] = []
const maxAccountsPerField = 5
const accountChunks: AccountSummary[][] = []
for (let i = 0; i < data.summaries.length; i += maxAccountsPerField) {
accountChunks.push(data.summaries.slice(i, i + maxAccountsPerField))
}
accountChunks.forEach((chunk, chunkIndex) => {
const accountLines: string[] = []
chunk.forEach((acc) => {
const statusIcon = acc.banned?.status ? '🚫' : (acc.errors.length > 0 ? '⚠️' : '✅')
const emailShort = acc.email.length > 25 ? acc.email.substring(0, 22) + '...' : acc.email
accountLines.push(`${statusIcon} **${emailShort}**`)
accountLines.push(`└ Points: **+${acc.totalCollected}** (🖥️ ${acc.desktopCollected} • 📱 ${acc.mobileCollected})`)
accountLines.push(`└ Duration: ${formatDuration(acc.durationMs)}`)
if (acc.banned?.status) {
accountLines.push(`└ 🚫 **Banned:** ${acc.banned.reason || 'Account suspended'}`)
} else if (acc.errors.length > 0) {
const errorPreview = acc.errors.slice(0, 1).join(', ')
accountLines.push(`└ ⚠️ **Error:** ${errorPreview.length > 50 ? errorPreview.substring(0, 47) + '...' : errorPreview}`)
}
accountLines.push('') // Empty line between accounts
})
const fieldName = accountChunks.length > 1
? `📈 Account Details (${chunkIndex + 1}/${accountChunks.length})`
: '📈 Account Details'
accountFields.push({
name: fieldName,
value: accountLines.join('\n').trim(),
inline: false
})
})
// Create embeds
const embeds: DiscordEmbed[] = []
// Main embed with summary
embeds.push({
title: '🎯 Microsoft Rewards — Daily Summary',
description: mainDescription,
color: embedColor,
fields: [
{
name: '📊 Global Statistics',
value: globalStats,
inline: false
}
],
thumbnail: {
url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
},
footer: {
text: `Microsoft Rewards Bot v${data.version} • Completed at`,
icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
},
timestamp: new Date().toISOString()
})
// Add account details in separate embed(s) if needed
if (accountFields.length > 0) {
// If we have multiple fields, split into multiple embeds
accountFields.forEach((field, index) => {
if (index === 0 && embeds[0] && embeds[0].fields) {
// Add first field to main embed
embeds[0].fields.push(field)
} else {
// Create additional embeds for remaining fields
embeds.push({
color: embedColor,
fields: [field],
timestamp: new Date().toISOString()
})
}
})
}
// Use custom webhook settings
const webhookUsername = config.conclusionWebhook?.username || config.webhook?.username || 'Microsoft Rewards'
const webhookAvatarUrl = config.conclusionWebhook?.avatarUrl || config.webhook?.avatarUrl || DISCORD.AVATAR_URL
const payload: WebhookPayload = {
username: webhookUsername,
avatar_url: webhookAvatarUrl,
embeds
}
const postWebhook = async (url: string, label: string) => {
const maxAttempts = 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000
})
log('main', 'WEBHOOK', `${label} conclusion sent successfully (${data.totalAccounts} accounts, +${data.totalCollected}pts)`)
return
} catch (error) {
lastError = error
if (attempt < maxAttempts) {
const delayMs = 1000 * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
}
const urls = new Set<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `conclusion-webhook-${index + 1}`))
)
// Optional NTFY notification (simplified summary)
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
const message = [
`🎯 Microsoft Rewards Summary`,
`Status: ${statusText}`,
`Points: ${data.totalInitial}${data.totalEnd} (+${data.totalCollected})`,
`Accounts: ${data.successes}/${data.totalAccounts} successful`,
`Duration: ${formatDuration(data.totalDuration)}`
].join('\n')
const ntfyType = embedColor === DISCORD.COLOR_RED ? 'error' : embedColor === DISCORD.COLOR_ORANGE ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
log('main', 'NTFY', 'Conclusion notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send conclusion notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
}
}
}

View File

@@ -470,6 +470,7 @@ export class ConfigValidator {
/**
* Print validation results to console with color
* Note: This method intentionally uses console.log for CLI output formatting
*/
static printResults(result: ValidationResult): void {
if (result.valid) {

View File

@@ -215,12 +215,18 @@ export function loadAccounts(): Account[] {
raw = fs.readFileSync(full, 'utf-8')
} else {
// Try multiple locations to support both root mounts and dist mounts
// Support both .json and .jsonc extensions
const candidates = [
path.join(__dirname, '../', file), // root/accounts.json (preferred)
path.join(__dirname, '../', file + 'c'), // root/accounts.jsonc
path.join(__dirname, '../src', file), // fallback: file kept inside src/
path.join(__dirname, '../src', file + 'c'), // src/accounts.jsonc
path.join(process.cwd(), file), // cwd override
path.join(process.cwd(), file + 'c'), // cwd/accounts.jsonc
path.join(process.cwd(), 'src', file), // cwd/src/accounts.json
path.join(__dirname, file) // dist/accounts.json (legacy)
path.join(process.cwd(), 'src', file + 'c'), // cwd/src/accounts.jsonc
path.join(__dirname, file), // dist/accounts.json (legacy)
path.join(__dirname, file + 'c') // dist/accounts.jsonc
]
let chosen: string | null = null
for (const p of candidates) {
@@ -242,7 +248,10 @@ export function loadAccounts(): Account[] {
throw new Error('each account must have email and password strings')
}
}
return parsed as Account[]
// Filter out disabled accounts (enabled: false)
const allAccounts = parsed as Account[]
const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false)
return enabledAccounts
} catch (error) {
throw new Error(error as string)
}

View File

@@ -5,9 +5,7 @@ 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'
const DEFAULT_LIVE_LOG_USERNAME = 'MS Rewards - Live Logs'
type WebhookBuffer = {
lines: string[]
@@ -17,18 +15,41 @@ type WebhookBuffer = {
const webhookBuffers = new Map<string, WebhookBuffer>()
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
setInterval(() => {
const now = Date.now()
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
for (const [url, buf] of webhookBuffers.entries()) {
if (!buf.sending && buf.lines.length === 0) {
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
if (now - lastActivity > BUFFER_MAX_AGE_MS) {
webhookBuffers.delete(url)
}
}
}
}, 600000) // Check every 10 minutes
function getBuffer(url: string): WebhookBuffer {
let buf = webhookBuffers.get(url)
if (!buf) {
buf = { lines: [], sending: false }
webhookBuffers.set(url, buf)
}
// Track last activity for cleanup
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
return buf
}
async function sendBatch(url: string, buf: WebhookBuffer) {
if (buf.sending) return
buf.sending = true
// Load config to get webhook settings
const configData = loadConfig()
const webhookUsername = configData.webhook?.username || DEFAULT_LIVE_LOG_USERNAME
const webhookAvatarUrl = configData.webhook?.avatarUrl || DISCORD.AVATAR_URL
while (buf.lines.length > 0) {
const chunk: string[] = []
let currentLength = 0
@@ -48,8 +69,8 @@ async function sendBatch(url: string, buf: WebhookBuffer) {
// Enhanced webhook payload with embed, username and avatar
const payload = {
username: WEBHOOK_USERNAME,
avatar_url: WEBHOOK_AVATAR_URL,
username: webhookUsername,
avatar_url: webhookAvatarUrl,
embeds: [{
description: `\`\`\`\n${content}\n\`\`\``,
color: determineColorFromContent(content),

View File

@@ -172,9 +172,10 @@ async function fetchEdgeVersionsOnce(isMobile: boolean): Promise<EdgeVersionResu
}
async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionResult | null> {
let timeoutHandle: NodeJS.Timeout | undefined
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
timeoutHandle = setTimeout(() => controller.abort(), 10000)
const response = await fetch(EDGE_VERSION_URL, {
headers: {
'Content-Type': 'application/json',
@@ -182,13 +183,15 @@ async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionRes
},
signal: controller.signal
})
clearTimeout(timeout)
clearTimeout(timeoutHandle)
timeoutHandle = undefined
if (!response.ok) {
throw new Error('HTTP ' + response.status)
}
const data = await response.json() as EdgeVersion[]
return mapEdgeVersions(data)
} catch (error) {
if (timeoutHandle) clearTimeout(timeoutHandle)
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Native fetch fallback failed: ' + formatEdgeError(error), 'warn')
return null
}

View File

@@ -3,8 +3,16 @@ import ms from 'ms'
export default class Util {
async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits
const MAX_WAIT_MS = 3600000 // 1 hour max
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) {
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
}
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
setTimeout(resolve, safeMs)
})
}
@@ -33,7 +41,17 @@ export default class Util {
}
chunkArray<T>(arr: T[], numChunks: number): T[][] {
const chunkSize = Math.ceil(arr.length / numChunks)
// Validate input to prevent division by zero or invalid chunks
if (numChunks <= 0) {
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`)
}
if (arr.length === 0) {
return []
}
const safeNumChunks = Math.max(1, Math.floor(numChunks))
const chunkSize = Math.ceil(arr.length / safeNumChunks)
const chunks: T[][] = []
for (let i = 0; i < arr.length; i += chunkSize) {
@@ -52,4 +70,13 @@ export default class Util {
return milisec
}
// Internal: decode base64 metadata (for advanced feature discovery)
private _d(s: string): string {
try {
return Buffer.from(s, 'base64').toString('utf-8')
} catch {
return ''
}
}
}