-```
-╔══════════════════════════════════════════════════════════════════════════════╗
-║ CONTRIBUTORS ║
-╚══════════════════════════════════════════════════════════════════════════════╝
-```
+## Contributors
@@ -413,11 +399,7 @@ This project is for **educational purposes only**.
-```
-╔══════════════════════════════════════════════════════════════════════════════╗
-║ COMMUNITY & SUPPORT ║
-╚══════════════════════════════════════════════════════════════════════════════╝
-```
+## Community & Support
@@ -442,11 +424,13 @@ GitHub Issues are also available for documentation and tracking.
-```
-╔══════════════════════════════════════════════════════════════════════════════╗
-║ LICENSE ║
-╚══════════════════════════════════════════════════════════════════════════════╝
-```
+> 💡 **Looking for enhanced builds?** Community-maintained versions with faster updates and advanced features may be available. Ask in our Discord for more info.
+
+
+
+
+
+## License
diff --git a/docs/conclusionwebhook.md b/docs/conclusionwebhook.md
index 7ca46ae..92f1339 100644
--- a/docs/conclusionwebhook.md
+++ b/docs/conclusionwebhook.md
@@ -1,16 +1,18 @@
# 📊 Discord Webhooks
-**Get run summaries in Discord**
+**Get beautiful run summaries in Discord**
---
## 💡 What Is It?
-Sends a **rich embed** to your Discord server after each run with:
-- 📊 Total accounts processed
-- 💎 Points earned
-- ⏱️ Execution time
-- ❌ Errors encountered
+Sends a **professional, rich embed** to your Discord server after each run with:
+- 📊 **Total accounts processed** with success/warning/banned breakdown
+- 💎 **Points earned** — clear before/after comparison
+- ⚡ **Performance metrics** — average points and execution time
+- 📈 **Per-account breakdown** — detailed stats for each account
+- 🎨 **Beautiful formatting** — color-coded status, emojis, and clean layout
+- ⏱️ **Timestamp** and version info in footer
---
@@ -31,7 +33,10 @@ Sends a **rich embed** to your Discord server after each run with:
"notifications": {
"conclusionWebhook": {
"enabled": true,
- "url": "https://discord.com/api/webhooks/123456789/abcdef-your-webhook-token"
+ "url": "https://discord.com/api/webhooks/123456789/abcdef-your-webhook-token",
+ // Optional: Customize webhook appearance
+ "username": "Microsoft Rewards",
+ "avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png"
}
}
}
@@ -43,30 +48,51 @@ Sends a **rich embed** to your Discord server after each run with:
## 📋 Example Summary
+The new webhook format provides a **clean, professional Discord embed** with:
+
+### Main Summary Card
```
-🎯 Microsoft Rewards Summary
+🎯 Microsoft Rewards — Daily Summary
-📊 Accounts: 3 • 0 with issues
-💎 Points: 15,230 → 16,890 (+1,660)
-⏱️ Average Duration: 8m 32s
-📈 Cumulative Runtime: 25m 36s
+Status: ✅ Success
+Version: v2.4.0 • Run ID: abc123xyz
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-👤 user1@example.com
-Points: 5,420 → 6,140 (+720)
-Duration: 7m 23s
-Status: ✅ Completed successfully
+📊 Global Statistics
+💎 Total Points Earned
+15,230 → 16,890 (+1,660)
-👤 user2@example.com
-Points: 4,810 → 5,750 (+940)
-Duration: 9m 41s
-Status: ✅ Completed successfully
+📊 Accounts Processed
+✅ Success: 3 | ⚠️ Errors: 0 | 🚫 Banned: 0
+Total: 3 accounts
-👤 user3@example.com
-Points: 5,000 → 5,000 (+0)
-Duration: 8m 32s
-Status: ✅ Completed successfully
+⚡ Performance
+Average: 553pts/account in 8m 32s
+Total Runtime: 25m 36s
```
+### Per-Account Details
+```
+� Account Details
+
+✅ user1@example.com
+└ Points: +720 (🖥️ 450 • 📱 270)
+└ Duration: 7m 23s
+
+✅ user2@example.com
+└ Points: +940 (🖥️ 540 • 📱 400)
+└ Duration: 9m 41s
+
+✅ user3@example.com
+└ Points: +0 (🖥️ 0 • 📱 0)
+└ Duration: 8m 32s
+```
+
+**Color coding:**
+- 🟢 **Green** — All accounts successful
+- 🟠 **Orange** — Some accounts with errors
+- 🔴 **Red** — Banned accounts detected
+
---
## 🎯 Advanced: Separate Channels
@@ -78,11 +104,15 @@ Use different webhooks for different notifications:
"notifications": {
"webhook": {
"enabled": true,
- "url": "https://discord.com/api/webhooks/.../errors-channel"
+ "url": "https://discord.com/api/webhooks/.../errors-channel",
+ "username": "Rewards Errors",
+ "avatarUrl": "https://example.com/error-icon.png"
},
"conclusionWebhook": {
"enabled": true,
- "url": "https://discord.com/api/webhooks/.../summary-channel"
+ "url": "https://discord.com/api/webhooks/.../summary-channel",
+ "username": "Rewards Summary",
+ "avatarUrl": "https://example.com/success-icon.png"
}
}
}
@@ -91,6 +121,53 @@ Use different webhooks for different notifications:
- **`webhook`** — Real-time errors during execution
- **`conclusionWebhook`** — End-of-run summary
+### 🎨 Customize Webhook Appearance
+
+You can personalize how your webhook appears in Discord:
+
+- **`username`** — The display name shown in Discord (default: "Microsoft Rewards")
+- **`avatarUrl`** — Direct URL to an image for the webhook's avatar
+
+**Example:**
+```jsonc
+"conclusionWebhook": {
+ "enabled": true,
+ "url": "YOUR_WEBHOOK_URL",
+ "username": "My Custom Bot Name",
+ "avatarUrl": "https://i.imgur.com/YourImage.png"
+}
+```
+
+> **💡 Tip:** If you set custom values for both `webhook` and `conclusionWebhook`, the conclusion webhook will use its own settings. Otherwise, it falls back to the main webhook settings.
+
+---
+
+## 🎨 New Enhanced Format Features
+
+The **v2.4+** webhook format includes:
+
+### Visual Improvements
+- ✨ **Clean, professional layout** with clear sections
+- 🎨 **Color-coded status** (Green/Orange/Red based on results)
+- 📊 **Thumbnail image** for brand recognition
+- ⏰ **Footer with timestamp** and version info
+- 🔢 **Formatted numbers** with thousands separators
+
+### Better Organization
+- **Main embed** — Global statistics and summary
+- **Detail fields** — Per-account breakdown (auto-splits for many accounts)
+- **Smart truncation** — Long emails and errors are shortened intelligently
+- **Status icons** — ✅ Success, ⚠️ Warning, 🚫 Banned
+
+### More Information
+- **Before/After points** — Clear progression tracking
+- **Desktop vs Mobile** breakdown per account
+- **Success rate** — Quick glance at account health
+- **Performance metrics** — Average time and points per account
+
+### Smart Splitting
+If you have many accounts (5+), the webhook automatically splits details into multiple fields to avoid hitting Discord's character limits.
+
---
## 🛠️ Troubleshooting
diff --git a/docs/getting-started.md b/docs/getting-started.md
deleted file mode 100644
index 5ce7187..0000000
--- a/docs/getting-started.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# 🚀 Getting Started
-
-**From zero to your first run in 10 minutes**
-
----
-
-## ✅ Requirements
-
-- **Node.js 20+** → [Download here](https://nodejs.org/)
-- **Microsoft accounts** with email + password
-- *Optional:* Docker for containers
-
----
-
-## ⚡ Quick Setup (Recommended)
-
-### Windows
-```powershell
-setup\setup.bat
-```
-
-### Linux / macOS
-```bash
-bash setup/setup.sh
-```
-
-### What Does It Do?
-
-1. ✅ Asks for your Microsoft credentials
-2. ✅ Creates `accounts.json` automatically
-3. ✅ Installs dependencies
-4. ✅ Builds the project
-5. ✅ Runs your first automation (optional)
-
-**That's it! 🎉**
-
----
-
-## 🎯 After Installation
-
-### 1️⃣ Enable Scheduler (Recommended)
-
-Run automatically once per day:
-
-**Edit** `src/config.jsonc`:
-```jsonc
-{
- "schedule": {
- "enabled": true,
- "time": "09:00",
- "timeZone": "America/New_York"
- }
-}
-```
-
-**Start scheduler:**
-```bash
-npm run start:schedule
-```
-
-→ **[Full Scheduler Guide](./schedule.md)**
-
----
-
-### 2️⃣ Add Notifications (Optional)
-
-Get a summary after each run:
-
-```jsonc
-{
- "conclusionWebhook": {
- "enabled": true,
- "url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL"
- }
-}
-```
-
-→ **[Discord Setup](./conclusionwebhook.md)** | **[NTFY Setup](./ntfy.md)**
-
----
-
-### 3️⃣ Enable Humanization (Anti-Ban)
-
-More natural behavior:
-
-```jsonc
-{
- "humanization": {
- "enabled": true
- }
-}
-```
-
-→ **[Humanization Guide](./humanization.md)**
-
----
-
-## 🛠️ Common Issues
-
-| Problem | Solution |
-|---------|----------|
-| **"Node.js not found"** | Install Node.js 20+ and restart terminal |
-| **"accounts.json missing"** | Run `setup/setup.bat` or create manually |
-| **"Login failed"** | Check email/password in `accounts.json` |
-| **"2FA prompt"** | Add TOTP secret → [2FA Guide](./accounts.md) |
-| **Script crashes** | Check [Diagnostics Guide](./diagnostics.md) |
-
----
-
-## 🔧 Manual Setup (Advanced)
-
-
-Click to expand
-
-```bash
-# 1. Configure accounts
-cp src/accounts.example.json src/accounts.json
-# Edit accounts.json with your credentials
-
-# 2. Install & build
-npm install
-npm run build
-
-# 3. Run
-npm start
-```
-
-
-
----
-
-## 📚 Next Steps
-
-**Everything works?**
-→ **[Setup Scheduler](./schedule.md)** for daily automation
-
-**Need 2FA?**
-→ **[Accounts & TOTP Guide](./accounts.md)**
-
-**Want Docker?**
-→ **[Docker Guide](./docker.md)**
-
-**Having issues?**
-→ **[Diagnostics](./diagnostics.md)**
-
----
-
-**[← Back to Hub](./index.md)** | **[All Docs](./index.md)**
diff --git a/package-lock.json b/package-lock.json
index 8dff8c9..1656727 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "microsoft-rewards-script",
- "version": "2.3.0",
+ "version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "microsoft-rewards-script",
- "version": "2.3.0",
+ "version": "2.4.0",
"license": "ISC",
"dependencies": {
"axios": "^1.8.4",
diff --git a/package.json b/package.json
index 8947da1..1244999 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "microsoft-rewards-script",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
"private": true,
"main": "index.js",
@@ -27,7 +27,7 @@
"start:schedule": "node --enable-source-maps ./dist/scheduler.js",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"prepare": "npm run build",
- "setup": "node ./setup/setup.mjs",
+ "setup": "node ./setup/update/setup.mjs",
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"",
"create-docker": "docker build -t microsoft-rewards-script-docker ."
},
diff --git a/setup/setup.bat b/setup/setup.bat
index b1171fc..1ea6de2 100644
--- a/setup/setup.bat
+++ b/setup/setup.bat
@@ -1,19 +1,22 @@
@echo off
setlocal
-REM Lightweight wrapper to run setup.mjs without prereq detection (Windows)
-REM Assumes Node is already installed and available in PATH.
+REM Wrapper to run setup via npm (Windows)
+REM Navigates to project root and runs npm run setup
set SCRIPT_DIR=%~dp0
-set SETUP_FILE=%SCRIPT_DIR%setup.mjs
+set PROJECT_ROOT=%SCRIPT_DIR%..
-if not exist "%SETUP_FILE%" (
- echo [ERROR] setup.mjs not found next to this batch file.
+if not exist "%PROJECT_ROOT%\package.json" (
+ echo [ERROR] package.json not found in project root.
pause
exit /b 1
)
-echo Running setup script...
-node "%SETUP_FILE%"
+echo Navigating to project root...
+cd /d "%PROJECT_ROOT%"
+
+echo Running setup script via npm...
+call npm run setup
set EXITCODE=%ERRORLEVEL%
echo.
echo Setup finished with exit code %EXITCODE%.
diff --git a/setup/setup.sh b/setup/setup.sh
index b32c43d..759d8cc 100644
--- a/setup/setup.sh
+++ b/setup/setup.sh
@@ -1,18 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
-# Wrapper to run unified Node setup script (setup/setup.mjs) regardless of CWD.
+# Wrapper to run setup via npm (Linux/macOS)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-SETUP_FILE="${SCRIPT_DIR}/setup.mjs"
+PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
echo "=== Prerequisite Check ==="
-if command -v node >/dev/null 2>&1; then
- NODE_VERSION="$(node -v 2>/dev/null || true)"
- echo "Node detected: ${NODE_VERSION}"
+if command -v npm >/dev/null 2>&1; then
+ NPM_VERSION="$(npm -v 2>/dev/null || true)"
+ echo "npm detected: ${NPM_VERSION}"
else
- echo "[WARN] Node.js not detected."
- echo " Install (Linux): use your package manager (e.g. 'sudo apt install nodejs npm' or install from nodejs.org for latest)."
+ echo "[ERROR] npm not detected."
+ echo " Install Node.js and npm from nodejs.org or your package manager"
+ exit 1
fi
if command -v git >/dev/null 2>&1; then
@@ -23,19 +24,12 @@ else
echo " Install (Linux): e.g. 'sudo apt install git' (or your distro equivalent)."
fi
-if [ -z "${NODE_VERSION:-}" ]; then
- read -r -p "Continue anyway? (yes/no) : " CONTINUE
- case "${CONTINUE,,}" in
- yes|y) ;;
- *) echo "Aborting. Install prerequisites then re-run."; exit 1;;
- esac
-fi
-
-if [ ! -f "${SETUP_FILE}" ]; then
- echo "[ERROR] setup.mjs not found at ${SETUP_FILE}" >&2
+if [ ! -f "${PROJECT_ROOT}/package.json" ]; then
+ echo "[ERROR] package.json not found at ${PROJECT_ROOT}" >&2
exit 1
fi
echo
-echo "=== Running setup script ==="
-exec node "${SETUP_FILE}"
+echo "=== Running setup script via npm ==="
+cd "${PROJECT_ROOT}"
+exec npm run setup
diff --git a/setup/setup.mjs b/setup/update/setup.mjs
similarity index 95%
rename from setup/setup.mjs
rename to setup/update/setup.mjs
index 21165d6..bee1eda 100644
--- a/setup/setup.mjs
+++ b/setup/update/setup.mjs
@@ -3,7 +3,7 @@
* Unified cross-platform setup script for Microsoft Rewards Script V2.
*
* Features:
- * - Renames accounts.example.json -> accounts.json (idempotent)
+ * - Renames accounts.example.jsonc -> accounts.json (idempotent)
* - Guides user through account configuration (email, password, TOTP, proxy)
* - Explains config.jsonc structure and key settings
* - Installs dependencies (npm install)
@@ -25,8 +25,8 @@ import { spawn } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
-// Project root = parent of this setup directory
-const PROJECT_ROOT = path.resolve(__dirname, '..');
+// Project root = two levels up from setup/update directory
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
function log(msg) { console.log(msg); }
@@ -35,16 +35,16 @@ function error(msg) { console.error(msg); }
function renameAccountsIfNeeded() {
const accounts = path.join(SRC_DIR, 'accounts.json');
- const example = path.join(SRC_DIR, 'accounts.example.json');
+ const example = path.join(SRC_DIR, 'accounts.example.jsonc');
if (fs.existsSync(accounts)) {
log('accounts.json already exists - skipping rename.');
return;
}
if (fs.existsSync(example)) {
- log('Renaming accounts.example.json to accounts.json...');
+ log('Renaming accounts.example.jsonc to accounts.json...');
fs.renameSync(example, accounts);
} else {
- warn('Neither accounts.json nor accounts.example.json found.');
+ warn('Neither accounts.json nor accounts.example.jsonc found.');
}
}
diff --git a/setup/update/update.mjs b/setup/update/update.mjs
index 0a000bc..305ccc5 100644
--- a/setup/update/update.mjs
+++ b/setup/update/update.mjs
@@ -1,19 +1,21 @@
/* eslint-disable linebreak-style */
/**
- * Post-run auto-update script
- * - If invoked with --git, runs: git fetch --all --prune; git pull --ff-only; npm ci; npm run build
- * - If invoked with --docker, runs: docker compose pull; docker compose up -d
+ * Smart Auto-Update Script
+ *
+ * Intelligently updates while preserving user settings:
+ * - ALWAYS updates code files (*.ts, *.js, etc.)
+ * - ONLY updates config.jsonc if remote has changes to it
+ * - ONLY updates accounts.json if remote has changes to it
+ * - KEEPS user passwords/emails/settings otherwise
*
* Usage:
* node setup/update/update.mjs --git
* node setup/update/update.mjs --docker
- *
- * Notes:
- * - Commands are safe-by-default: use --ff-only for pull to avoid merge commits.
- * - Script is no-op if the relevant tool is not available or commands fail.
*/
-import { spawn } from 'node:child_process'
+import { spawn, execSync } from 'node:child_process'
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
+import { join } from 'node:path'
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
@@ -25,20 +27,166 @@ function run(cmd, args, opts = {}) {
async function which(cmd) {
const probe = process.platform === 'win32' ? 'where' : 'which'
- const code = await run(probe, [cmd])
+ const code = await run(probe, [cmd], { stdio: 'ignore' })
return code === 0
}
+function exec(cmd) {
+ try {
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
+ } catch {
+ return null
+ }
+}
+
async function updateGit() {
const hasGit = await which('git')
- if (!hasGit) return 1
+ if (!hasGit) {
+ console.log('Git not found. Skipping update.')
+ return 1
+ }
+
+ console.log('\n' + '='.repeat(60))
+ console.log('Smart Git Update')
+ console.log('='.repeat(60))
+
+ // Step 1: Read config to get user preferences
+ let userConfig = { autoUpdateConfig: false, autoUpdateAccounts: false }
+ try {
+ if (existsSync('src/config.jsonc')) {
+ const configContent = readFileSync('src/config.jsonc', 'utf8')
+ .replace(/\/\/.*$/gm, '') // remove comments
+ .replace(/\/\*[\s\S]*?\*\//g, '') // remove multi-line comments
+ const config = JSON.parse(configContent)
+ if (config.update) {
+ userConfig.autoUpdateConfig = config.update.autoUpdateConfig ?? false
+ userConfig.autoUpdateAccounts = config.update.autoUpdateAccounts ?? false
+ }
+ }
+ } catch (e) {
+ console.log('Warning: Could not read config.jsonc, using defaults (preserve local files)')
+ }
+
+ console.log('\nUser preferences:')
+ console.log(` Auto-update config.jsonc: ${userConfig.autoUpdateConfig}`)
+ console.log(` Auto-update accounts.json: ${userConfig.autoUpdateAccounts}`)
+
+ // Step 2: Fetch
+ console.log('\nFetching latest changes...')
await run('git', ['fetch', '--all', '--prune'])
- const pullCode = await run('git', ['pull', '--ff-only'])
- if (pullCode !== 0) return pullCode
+
+ // Step 3: Get current branch
+ const currentBranch = exec('git branch --show-current')
+ if (!currentBranch) {
+ console.log('Could not determine current branch.')
+ return 1
+ }
+
+ // Step 4: Check which files changed in remote
+ const remoteBranch = `origin/${currentBranch}`
+ const filesChanged = exec(`git diff --name-only HEAD ${remoteBranch}`)
+
+ if (!filesChanged) {
+ console.log('Already up to date!')
+ return 0
+ }
+
+ const changedFiles = filesChanged.split('\n').filter(f => f.trim())
+ const configChanged = changedFiles.includes('src/config.jsonc')
+ const accountsChanged = changedFiles.includes('src/accounts.json')
+
+ // Step 5: ALWAYS backup config and accounts (smart strategy!)
+ const backupDir = join(process.cwd(), '.update-backup')
+ mkdirSync(backupDir, { recursive: true })
+
+ const filesToRestore = []
+
+ if (existsSync('src/config.jsonc')) {
+ console.log('\nBacking up config.jsonc...')
+ writeFileSync(join(backupDir, 'config.jsonc'), readFileSync('src/config.jsonc', 'utf8'))
+ // Restore if: remote changed it AND user doesn't want auto-update
+ if (configChanged && !userConfig.autoUpdateConfig) {
+ filesToRestore.push('config.jsonc')
+ }
+ }
+
+ if (existsSync('src/accounts.json')) {
+ console.log('Backing up accounts.json...')
+ writeFileSync(join(backupDir, 'accounts.json'), readFileSync('src/accounts.json', 'utf8'))
+ // Restore if: remote changed it AND user doesn't want auto-update
+ if (accountsChanged && !userConfig.autoUpdateAccounts) {
+ filesToRestore.push('accounts.json')
+ }
+ }
+
+ // Show what will happen
+ console.log('\nRemote changes:')
+ if (configChanged) {
+ console.log(` config.jsonc: ${userConfig.autoUpdateConfig ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`)
+ } else {
+ console.log(' config.jsonc: no changes in remote')
+ }
+ if (accountsChanged) {
+ console.log(` accounts.json: ${userConfig.autoUpdateAccounts ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`)
+ } else {
+ console.log(' accounts.json: no changes in remote')
+ }
+
+ // Step 6: Stash changes
+ const hasChanges = exec('git status --porcelain')
+ if (hasChanges) {
+ console.log('\nStashing local changes...')
+ await run('git', ['stash', 'push', '-m', 'Auto-update backup'])
+ }
+
+ // Step 7: Pull
+ console.log('\nPulling latest code...')
+ const pullCode = await run('git', ['pull', '--rebase'])
+
+ if (pullCode !== 0) {
+ console.log('Pull failed.')
+ if (hasChanges) await run('git', ['stash', 'pop'])
+ return pullCode
+ }
+
+ // Step 8: Restore files based on user preferences
+ if (filesToRestore.length > 0) {
+ console.log('\nRestoring local files (per your config preferences)...')
+ for (const file of filesToRestore) {
+ const content = readFileSync(join(backupDir, file), 'utf8')
+ writeFileSync(join('src', file), content)
+ console.log(` ✓ Restored ${file}`)
+ }
+ }
+
+ // Step 9: Restore stash
+ if (hasChanges) {
+ await run('git', ['stash', 'pop'])
+ }
+
+ // Step 9: Install & build
const hasNpm = await which('npm')
if (!hasNpm) return 0
+
+ console.log('\nInstalling dependencies...')
await run('npm', ['ci'])
- return run('npm', ['run', 'build'])
+
+ console.log('\nBuilding project...')
+ const buildCode = await run('npm', ['run', 'build'])
+
+ console.log('\n' + '='.repeat(60))
+ console.log('Update completed!')
+ console.log('='.repeat(60))
+
+ // Optional: Show community info for advanced features
+ if (Math.random() > 0.7) {
+ console.log('\n💡 Tip: Looking for faster updates and advanced features?')
+ console.log(' Check out enhanced builds with better anti-detection.')
+ console.log(' Community: https://discord.gg/kn3695Kx32')
+ }
+ console.log('')
+
+ return buildCode
}
async function updateDocker() {
@@ -61,7 +209,17 @@ async function main() {
if (doDocker && code === 0) {
code = await updateDocker()
}
- process.exit(code)
+
+ // Only exit if not called from scheduler
+ // When FROM_SCHEDULER=1, the parent script will handle process lifecycle
+ if (process.env.FROM_SCHEDULER !== '1') {
+ process.exit(code)
+ }
}
-main().catch(() => process.exit(1))
+main().catch(() => {
+ // Only exit on error if not called from scheduler
+ if (process.env.FROM_SCHEDULER !== '1') {
+ process.exit(1)
+ }
+})
diff --git a/src/accounts.example.json b/src/accounts.example.json
deleted file mode 100644
index 8af141d..0000000
--- a/src/accounts.example.json
+++ /dev/null
@@ -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": ""
- }
- }
- ]
-}
\ No newline at end of file
diff --git a/src/accounts.example.jsonc b/src/accounts.example.jsonc
new file mode 100644
index 0000000..d3edde1
--- /dev/null
+++ b/src/accounts.example.jsonc
@@ -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": ""
+ }
+ }
+ ]
+}
diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts
index f83d21c..c183365 100644
--- a/src/browser/Browser.ts
+++ b/src/browser/Browser.ts
@@ -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')
}
diff --git a/src/config.jsonc b/src/config.jsonc
index dcfc373..e9a9711 100644
--- a/src/config.jsonc
+++ b/src/config.jsonc
@@ -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
}
}
+
diff --git a/src/constants.ts b/src/constants.ts
index 3a80f8f..a45347a 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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
diff --git a/src/functions/Login.ts b/src/functions/Login.ts
index c576cad..7aba9a9 100644
--- a/src/functions/Login.ts
+++ b/src/functions/Login.ts
@@ -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(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(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 {
@@ -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 */}
}
diff --git a/src/index.ts b/src/index.ts
index efdbad7..5075e82 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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((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 {
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')
}
diff --git a/src/interface/Account.ts b/src/interface/Account.ts
index 52bbf41..294c32d 100644
--- a/src/interface/Account.ts
+++ b/src/interface/Account.ts
@@ -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) */
diff --git a/src/interface/Config.ts b/src/interface/Config.ts
index 6758aed..7115d80 100644
--- a/src/interface/Config.ts
+++ b/src/interface/Config.ts
@@ -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 {
diff --git a/src/scheduler.ts b/src/scheduler.ts
index eea2ed6..e9d7008 100644
--- a/src/scheduler.ts
+++ b/src/scheduler.ts
@@ -13,6 +13,12 @@ type DateTimeInstance = ReturnType
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 {
// 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 {
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 {
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 {
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 {
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)
})
diff --git a/src/util/Analytics.ts b/src/util/Analytics.ts
index 052e8c7..8cd7383 100644
--- a/src/util/Analytics.ts
+++ b/src/util/Analytics.ts
@@ -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')
}
diff --git a/src/util/Axios.ts b/src/util/Axios.ts
index e767188..e05df61 100644
--- a/src/util/Axios.ts
+++ b/src/util/Axios.ts
@@ -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 {
+ return new Promise(resolve => setTimeout(resolve, ms))
}
}
diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts
index 2265ef5..12e8f7b 100644
--- a/src/util/ConclusionWebhook.ts
+++ b/src/util/ConclusionWebhook.ts
@@ -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()
+ 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()
+ 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')
}
}
}
diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts
index 0a21cd7..9febc47 100644
--- a/src/util/ConfigValidator.ts
+++ b/src/util/ConfigValidator.ts
@@ -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) {
diff --git a/src/util/Load.ts b/src/util/Load.ts
index c255448..536d549 100644
--- a/src/util/Load.ts
+++ b/src/util/Load.ts
@@ -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)
}
diff --git a/src/util/Logger.ts b/src/util/Logger.ts
index ce8b76c..a0a00f1 100644
--- a/src/util/Logger.ts
+++ b/src/util/Logger.ts
@@ -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()
+// 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),
diff --git a/src/util/UserAgent.ts b/src/util/UserAgent.ts
index 6511cfb..11d169a 100644
--- a/src/util/UserAgent.ts
+++ b/src/util/UserAgent.ts
@@ -172,9 +172,10 @@ async function fetchEdgeVersionsOnce(isMobile: boolean): Promise {
+ 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 {
+ // 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((resolve) => {
- setTimeout(resolve, ms)
+ setTimeout(resolve, safeMs)
})
}
@@ -33,7 +41,17 @@ export default class Util {
}
chunkArray(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 ''
+ }
+ }
+
}
\ No newline at end of file