Revert "V2.4.0 (#381)" (#392)

This reverts commit f2d00225c9.
This commit is contained in:
Netsky
2025-10-23 13:36:09 +02:00
committed by GitHub
parent f2d00225c9
commit 2a8ab7242f
28 changed files with 690 additions and 1333 deletions

4
.gitignore vendored
View File

@@ -4,13 +4,9 @@ node_modules/
.vscode/ .vscode/
.github/ .github/
diagnostic/ diagnostic/
report/
accounts.json accounts.json
accounts.jsonc
notes notes
accounts.dev.json accounts.dev.json
accounts.dev.jsonc
accounts.main.json accounts.main.json
accounts.main.jsonc
.DS_Store .DS_Store
.playwright-chromium-installed .playwright-chromium-installed

110
README.md
View File

@@ -17,7 +17,7 @@
<br> <br>
![Version](https://img.shields.io/badge/v2.4.0-blue?style=for-the-badge&logo=github&logoColor=white) ![Version](https://img.shields.io/badge/v2.3.0-blue?style=for-the-badge&logo=github&logoColor=white)
![License](https://img.shields.io/badge/ISC-00D9FF?style=for-the-badge) ![License](https://img.shields.io/badge/ISC-00D9FF?style=for-the-badge)
![Stars](https://img.shields.io/github/stars/TheNetsky/Microsoft-Rewards-Script?style=for-the-badge&color=blue) ![Stars](https://img.shields.io/github/stars/TheNetsky/Microsoft-Rewards-Script?style=for-the-badge&color=blue)
![Status](https://img.shields.io/badge/Active-00C851?style=for-the-badge) ![Status](https://img.shields.io/badge/Active-00C851?style=for-the-badge)
@@ -31,36 +31,13 @@
<br> <br>
--- ```
╔══════════════════════════════════════════════════════════════════════════════╗
<div align="center"> ║ WHAT DOES THIS DO? ║
╚══════════════════════════════════════════════════════════════════════════════╝
### 📌 **Update Notice**
Recent updates changed the structure of `config.jsonc` and `accounts.jsonc` files (including extensions).
**If you see Git conflicts during `git pull` on these files:**
```bash
# Delete and fresh clone
rm -rf Microsoft-Rewards-Script
git clone -b v2 https://github.com/TheNetsky/Microsoft-Rewards-Script.git
cd Microsoft-Rewards-Script
# Manually re-enter your settings in the new files
``` ```
⚠️ Don't copy old config files directly—structure has changed. Re-enter your credentials and preferences manually. <div align="center">
This notice will remain for a few releases. Once we reach stable v2.5+, automatic updates will work smoothly again.
</div>
---
<br>
## What Does This Do?
**Automate your Microsoft Rewards daily activities with intelligent browser automation.** **Automate your Microsoft Rewards daily activities with intelligent browser automation.**
Complete searches, quizzes, and promotions automatically while mimicking natural human behavior. Complete searches, quizzes, and promotions automatically while mimicking natural human behavior.
@@ -76,11 +53,16 @@ Complete searches, quizzes, and promotions automatically while mimicking natural
| **Daily Set Tasks** | ~30-50 pts | 1-2 min | | **Daily Set Tasks** | ~30-50 pts | 1-2 min |
| **Promotions & Punch Cards** | Variable | 30s-2min | | **Promotions & Punch Cards** | Variable | 30s-2min |
| **📊 TOTAL AVERAGE** | **150-300+ pts** | **3-5 min** | | **📊 TOTAL AVERAGE** | **150-300+ pts** | **3-5 min** |
</div> </div>
<br> <br>
## Quick Start ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ QUICK START ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
### **🚀 Automated Setup** (Recommended) ### **🚀 Automated Setup** (Recommended)
@@ -110,7 +92,7 @@ git clone -b v2 https://github.com/TheNetsky/Microsoft-Rewards-Script.git
cd Microsoft-Rewards-Script cd Microsoft-Rewards-Script
# 2. Configure accounts # 2. Configure accounts
cp src/accounts.example.jsonc src/accounts.json cp src/accounts.example.json src/accounts.json
# Edit accounts.json with your Microsoft credentials # Edit accounts.json with your Microsoft credentials
# 3. Install & build # 3. Install & build
@@ -122,7 +104,11 @@ npm start
<br> <br>
## Intelligent Features ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ INTELLIGENT FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<table> <table>
<tr> <tr>
@@ -173,7 +159,11 @@ Pre-flight checks
<br> <br>
## Usage Commands ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ USAGE COMMANDS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
```bash ```bash
# Run automation once # Run automation once
@@ -194,7 +184,11 @@ npm start -- --dry-run
<br> <br>
## Configuration ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONFIGURATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
Edit `src/config.jsonc` to customize behavior: Edit `src/config.jsonc` to customize behavior:
@@ -226,7 +220,11 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
## Core Features ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CORE FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -271,7 +269,11 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
## Documentation ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ DOCUMENTATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -288,7 +290,11 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
## Technical Architecture ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ TECHNICAL ARCHITECTURE ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -321,7 +327,11 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
## Important Disclaimers ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ IMPORTANT DISCLAIMERS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -355,7 +365,11 @@ This project is for **educational purposes only**.
<br> <br>
## Contributors ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONTRIBUTORS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -399,7 +413,11 @@ This project is for **educational purposes only**.
<br> <br>
## Community & Support ```
╔══════════════════════════════════════════════════════════════════════════════╗
║ COMMUNITY & SUPPORT ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -424,13 +442,11 @@ GitHub Issues are also available for documentation and tracking.
<br> <br>
> 💡 **Looking for enhanced builds?** Community-maintained versions with faster updates and advanced features may be available. Ask in our Discord for more info. ```
╔══════════════════════════════════════════════════════════════════════════════╗
</div> ║ LICENSE ║
╚══════════════════════════════════════════════════════════════════════════════╝
<br> ```
## License
<div align="center"> <div align="center">

View File

@@ -1,18 +1,16 @@
# 📊 Discord Webhooks # 📊 Discord Webhooks
**Get beautiful run summaries in Discord** **Get run summaries in Discord**
--- ---
## 💡 What Is It? ## 💡 What Is It?
Sends a **professional, rich embed** to your Discord server after each run with: Sends a **rich embed** to your Discord server after each run with:
- 📊 **Total accounts processed** with success/warning/banned breakdown - 📊 Total accounts processed
- 💎 **Points earned** — clear before/after comparison - 💎 Points earned
- **Performance metrics** — average points and execution time - ⏱️ Execution time
- 📈 **Per-account breakdown** — detailed stats for each account - ❌ Errors encountered
- 🎨 **Beautiful formatting** — color-coded status, emojis, and clean layout
- ⏱️ **Timestamp** and version info in footer
--- ---
@@ -33,10 +31,7 @@ Sends a **professional, rich embed** to your Discord server after each run with:
"notifications": { "notifications": {
"conclusionWebhook": { "conclusionWebhook": {
"enabled": true, "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"
} }
} }
} }
@@ -48,51 +43,30 @@ Sends a **professional, rich embed** to your Discord server after each run with:
## 📋 Example Summary ## 📋 Example Summary
The new webhook format provides a **clean, professional Discord embed** with:
### Main Summary Card
``` ```
🎯 Microsoft Rewards — Daily Summary 🎯 Microsoft Rewards Summary
Status: ✅ Success 📊 Accounts: 3 • 0 with issues
Version: v2.4.0 • Run ID: abc123xyz 💎 Points: 15,230 → 16,890 (+1,660)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏱️ Average Duration: 8m 32s
📈 Cumulative Runtime: 25m 36s
📊 Global Statistics 👤 user1@example.com
💎 Total Points Earned Points: 5,420 → 6,140 (+720)
15,230 → 16,890 (+1,660) Duration: 7m 23s
Status: ✅ Completed successfully
📊 Accounts Processed 👤 user2@example.com
✅ Success: 3 | ⚠️ Errors: 0 | 🚫 Banned: 0 Points: 4,810 → 5,750 (+940)
Total: 3 accounts Duration: 9m 41s
Status: ✅ Completed successfully
⚡ Performance 👤 user3@example.com
Average: 553pts/account in 8m 32s Points: 5,000 → 5,000 (+0)
Total Runtime: 25m 36s Duration: 8m 32s
Status: ✅ Completed successfully
``` ```
### Per-Account Details
```
<EFBFBD> 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 ## 🎯 Advanced: Separate Channels
@@ -104,15 +78,11 @@ Use different webhooks for different notifications:
"notifications": { "notifications": {
"webhook": { "webhook": {
"enabled": true, "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": { "conclusionWebhook": {
"enabled": true, "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"
} }
} }
} }
@@ -121,53 +91,6 @@ Use different webhooks for different notifications:
- **`webhook`** — Real-time errors during execution - **`webhook`** — Real-time errors during execution
- **`conclusionWebhook`** — End-of-run summary - **`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 ## 🛠️ Troubleshooting

148
docs/getting-started.md Normal file
View File

@@ -0,0 +1,148 @@
# 🚀 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)
<details>
<summary><strong>Click to expand</strong></summary>
```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
```
</details>
---
## 📚 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)**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "microsoft-rewards-script", "name": "microsoft-rewards-script",
"version": "2.4.0", "version": "2.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "microsoft-rewards-script", "name": "microsoft-rewards-script",
"version": "2.4.0", "version": "2.3.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.8.4", "axios": "^1.8.4",

View File

@@ -1,6 +1,6 @@
{ {
"name": "microsoft-rewards-script", "name": "microsoft-rewards-script",
"version": "2.4.0", "version": "2.3.0",
"description": "Automatically do tasks for Microsoft Rewards but in TS!", "description": "Automatically do tasks for Microsoft Rewards but in TS!",
"private": true, "private": true,
"main": "index.js", "main": "index.js",
@@ -27,7 +27,7 @@
"start:schedule": "node --enable-source-maps ./dist/scheduler.js", "start:schedule": "node --enable-source-maps ./dist/scheduler.js",
"lint": "eslint \"src/**/*.{ts,tsx}\"", "lint": "eslint \"src/**/*.{ts,tsx}\"",
"prepare": "npm run build", "prepare": "npm run build",
"setup": "node ./setup/update/setup.mjs", "setup": "node ./setup/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 }\"", "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 ." "create-docker": "docker build -t microsoft-rewards-script-docker ."
}, },

View File

@@ -1,22 +1,19 @@
@echo off @echo off
setlocal setlocal
REM Wrapper to run setup via npm (Windows) REM Lightweight wrapper to run setup.mjs without prereq detection (Windows)
REM Navigates to project root and runs npm run setup REM Assumes Node is already installed and available in PATH.
set SCRIPT_DIR=%~dp0 set SCRIPT_DIR=%~dp0
set PROJECT_ROOT=%SCRIPT_DIR%.. set SETUP_FILE=%SCRIPT_DIR%setup.mjs
if not exist "%PROJECT_ROOT%\package.json" ( if not exist "%SETUP_FILE%" (
echo [ERROR] package.json not found in project root. echo [ERROR] setup.mjs not found next to this batch file.
pause pause
exit /b 1 exit /b 1
) )
echo Navigating to project root... echo Running setup script...
cd /d "%PROJECT_ROOT%" node "%SETUP_FILE%"
echo Running setup script via npm...
call npm run setup
set EXITCODE=%ERRORLEVEL% set EXITCODE=%ERRORLEVEL%
echo. echo.
echo Setup finished with exit code %EXITCODE%. echo Setup finished with exit code %EXITCODE%.

View File

@@ -3,7 +3,7 @@
* Unified cross-platform setup script for Microsoft Rewards Script V2. * Unified cross-platform setup script for Microsoft Rewards Script V2.
* *
* Features: * Features:
* - Renames accounts.example.jsonc -> accounts.json (idempotent) * - Renames accounts.example.json -> accounts.json (idempotent)
* - Guides user through account configuration (email, password, TOTP, proxy) * - Guides user through account configuration (email, password, TOTP, proxy)
* - Explains config.jsonc structure and key settings * - Explains config.jsonc structure and key settings
* - Installs dependencies (npm install) * - Installs dependencies (npm install)
@@ -25,8 +25,8 @@ import { spawn } from 'child_process';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Project root = two levels up from setup/update directory // Project root = parent of this setup directory
const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); const PROJECT_ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(PROJECT_ROOT, 'src'); const SRC_DIR = path.join(PROJECT_ROOT, 'src');
function log(msg) { console.log(msg); } function log(msg) { console.log(msg); }
@@ -35,16 +35,16 @@ function error(msg) { console.error(msg); }
function renameAccountsIfNeeded() { function renameAccountsIfNeeded() {
const accounts = path.join(SRC_DIR, 'accounts.json'); const accounts = path.join(SRC_DIR, 'accounts.json');
const example = path.join(SRC_DIR, 'accounts.example.jsonc'); const example = path.join(SRC_DIR, 'accounts.example.json');
if (fs.existsSync(accounts)) { if (fs.existsSync(accounts)) {
log('accounts.json already exists - skipping rename.'); log('accounts.json already exists - skipping rename.');
return; return;
} }
if (fs.existsSync(example)) { if (fs.existsSync(example)) {
log('Renaming accounts.example.jsonc to accounts.json...'); log('Renaming accounts.example.json to accounts.json...');
fs.renameSync(example, accounts); fs.renameSync(example, accounts);
} else { } else {
warn('Neither accounts.json nor accounts.example.jsonc found.'); warn('Neither accounts.json nor accounts.example.json found.');
} }
} }

View File

@@ -1,19 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Wrapper to run setup via npm (Linux/macOS) # Wrapper to run unified Node setup script (setup/setup.mjs) regardless of CWD.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" SETUP_FILE="${SCRIPT_DIR}/setup.mjs"
echo "=== Prerequisite Check ===" echo "=== Prerequisite Check ==="
if command -v npm >/dev/null 2>&1; then if command -v node >/dev/null 2>&1; then
NPM_VERSION="$(npm -v 2>/dev/null || true)" NODE_VERSION="$(node -v 2>/dev/null || true)"
echo "npm detected: ${NPM_VERSION}" echo "Node detected: ${NODE_VERSION}"
else else
echo "[ERROR] npm not detected." echo "[WARN] Node.js not detected."
echo " Install Node.js and npm from nodejs.org or your package manager" echo " Install (Linux): use your package manager (e.g. 'sudo apt install nodejs npm' or install from nodejs.org for latest)."
exit 1
fi fi
if command -v git >/dev/null 2>&1; then if command -v git >/dev/null 2>&1; then
@@ -24,12 +23,19 @@ else
echo " Install (Linux): e.g. 'sudo apt install git' (or your distro equivalent)." echo " Install (Linux): e.g. 'sudo apt install git' (or your distro equivalent)."
fi fi
if [ ! -f "${PROJECT_ROOT}/package.json" ]; then if [ -z "${NODE_VERSION:-}" ]; then
echo "[ERROR] package.json not found at ${PROJECT_ROOT}" >&2 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
exit 1 exit 1
fi fi
echo echo
echo "=== Running setup script via npm ===" echo "=== Running setup script ==="
cd "${PROJECT_ROOT}" exec node "${SETUP_FILE}"
exec npm run setup

View File

@@ -1,21 +1,19 @@
/* eslint-disable linebreak-style */ /* eslint-disable linebreak-style */
/** /**
* Smart Auto-Update Script * Post-run auto-update script
* * - If invoked with --git, runs: git fetch --all --prune; git pull --ff-only; npm ci; npm run build
* Intelligently updates while preserving user settings: * - If invoked with --docker, runs: docker compose pull; docker compose up -d
* - 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: * Usage:
* node setup/update/update.mjs --git * node setup/update/update.mjs --git
* node setup/update/update.mjs --docker * 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, execSync } from 'node:child_process' import { spawn } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -27,166 +25,20 @@ function run(cmd, args, opts = {}) {
async function which(cmd) { async function which(cmd) {
const probe = process.platform === 'win32' ? 'where' : 'which' const probe = process.platform === 'win32' ? 'where' : 'which'
const code = await run(probe, [cmd], { stdio: 'ignore' }) const code = await run(probe, [cmd])
return code === 0 return code === 0
} }
function exec(cmd) {
try {
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
} catch {
return null
}
}
async function updateGit() { async function updateGit() {
const hasGit = await which('git') const hasGit = await which('git')
if (!hasGit) { if (!hasGit) return 1
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']) await run('git', ['fetch', '--all', '--prune'])
const pullCode = await run('git', ['pull', '--ff-only'])
// Step 3: Get current branch if (pullCode !== 0) return pullCode
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') const hasNpm = await which('npm')
if (!hasNpm) return 0 if (!hasNpm) return 0
console.log('\nInstalling dependencies...')
await run('npm', ['ci']) 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() { async function updateDocker() {
@@ -209,17 +61,7 @@ async function main() {
if (doDocker && code === 0) { if (doDocker && code === 0) {
code = await updateDocker() code = await updateDocker()
} }
// 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) process.exit(code)
}
} }
main().catch(() => { main().catch(() => process.exit(1))
// Only exit on error if not called from scheduler
if (process.env.FROM_SCHEDULER !== '1') {
process.exit(1)
}
})

31
src/accounts.example.json Normal file
View File

@@ -0,0 +1,31 @@
{
"_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": ""
}
}
]
}

View File

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

View File

@@ -54,8 +54,7 @@ class Browser {
const engineName = 'chromium' // current hard-coded engine const engineName = 'chromium' // current hard-coded engine
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
browser = await playwright.chromium.launch({ browser = await playwright.chromium.launch({
// Optional: uncomment to use Edge instead of Chromium //channel: 'msedge', // Uses Edge instead of chrome
// channel: 'msedge',
headless, headless,
...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }), ...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }),
args: [ args: [
@@ -71,7 +70,7 @@ class Browser {
const msg = (e instanceof Error ? e.message : String(e)) const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance // Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) { if (/Executable doesn't exist/i.test(msg)) {
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') 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')
} else { } else {
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error') this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
} }

View File

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

View File

@@ -63,12 +63,5 @@ export const DISCORD = {
COLOR_CRIMSON: 0xDC143C, COLOR_CRIMSON: 0xDC143C,
COLOR_ORANGE: 0xFFA500, COLOR_ORANGE: 0xFFA500,
COLOR_BLUE: 0x3498DB, 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 } as const

View File

@@ -28,14 +28,7 @@ const SELECTORS = {
const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' } const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
const DEFAULT_TIMEOUTS = { const DEFAULT_TIMEOUTS = {
loginMaxMs: (() => { loginMaxMs: Number(process.env.LOGIN_MAX_WAIT_MS || 180000), // 3 min
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, short: 500,
medium: 1500, medium: 1500,
long: 3000 long: 3000
@@ -78,12 +71,6 @@ export class Login {
// --------------- Public API --------------- // --------------- Public API ---------------
async login(page: Page, email: string, password: string, totpSecret?: string) { async login(page: Page, email: string, password: string, totpSecret?: string) {
try { 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.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
@@ -298,7 +285,6 @@ export class Login {
let userInput: string | null = null let userInput: string | null = null
let checkInterval: NodeJS.Timeout | null = null let checkInterval: NodeJS.Timeout | null = null
try {
const inputPromise = new Promise<string>(res => { const inputPromise = new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => { rl.question('Enter 2FA code:\n', ans => {
if (checkInterval) clearInterval(checkInterval) if (checkInterval) clearInterval(checkInterval)
@@ -323,6 +309,7 @@ export class Login {
}, 2000) }, 2000)
const code = await inputPromise const code = await inputPromise
if (checkInterval) clearInterval(checkInterval)
if (code === 'skip' || userInput === 'skip') { if (code === 'skip' || userInput === 'skip') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)') this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)')
@@ -332,11 +319,6 @@ export class Login {
await page.fill('input[name="otc"]', code) await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted') 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 */}
}
} }
private async ensureTotpInput(page: Page): Promise<string | null> { private async ensureTotpInput(page: Page): Promise<string | null> {
@@ -776,19 +758,12 @@ export class Login {
this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level) this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level)
try { try {
const { ConclusionWebhook } = await import('../util/ConclusionWebhook') const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
const fields = [ 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 }, { name:'Account', value: incident.account },
...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []), ...(incident.details?.length?[{ name:'Details', value: incident.details.join('\n') }]:[]),
...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []), ...(incident.next?.length?[{ name:'Next steps', value: incident.next.join('\n') }]:[]),
...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : []) ...(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 */} } catch {/* ignore */}
} }

View File

@@ -43,9 +43,6 @@ export class MicrosoftRewardsBot {
public compromisedModeActive: boolean = false public compromisedModeActive: boolean = false
public compromisedReason?: string public compromisedReason?: string
public compromisedEmail?: 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 pointsCanCollect: number = 0
private pointsInitial: number = 0 private pointsInitial: number = 0
@@ -188,13 +185,24 @@ export class MicrosoftRewardsBot {
const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => { const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => {
try { try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook( const title = '💳 Spend detected (Buy Mode)'
this.config, const desc = [
'💳 Spend Detected', `Account: ${account.email}`,
`**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`, `Spent: -${delta} points`,
undefined, `Current: ${nowPts} points`,
0xFFAA00 `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
}
]
})
} catch (e) { } catch (e) {
this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn') this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn')
} }
@@ -253,11 +261,7 @@ export class MicrosoftRewardsBot {
} }
// Save cookies and close monitor; keep main page open for user until they close it themselves // Save cookies and close monitor; keep main page open for user until they close it themselves
try { try { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch { /* ignore */ }
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 */} try { if (!monitor.isClosed()) await monitor.close() } catch {/* ignore */}
// Send a final minimal conclusion webhook for this manual session // Send a final minimal conclusion webhook for this manual session
@@ -314,20 +318,16 @@ export class MicrosoftRewardsBot {
╚══════════════════════════════════════════════════════╝ ╚══════════════════════════════════════════════════════╝
` `
// Read package version and build banner info try {
const pkgPath = path.join(__dirname, '../', 'package.json') const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown' let version = 'unknown'
try {
if (fs.existsSync(pkgPath)) { if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, 'utf-8') const raw = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(raw) const pkg = JSON.parse(raw)
version = pkg.version || version version = pkg.version || version
} }
} catch {
// Ignore version read errors
}
// Display appropriate banner based on mode // Show appropriate banner based on mode
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner) console.log(displayBanner)
console.log('='.repeat(80)) console.log('='.repeat(80))
@@ -376,9 +376,19 @@ export class MicrosoftRewardsBot {
} }
} }
console.log('='.repeat(80) + '\n') 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() { public getSummaries() {
return this.accountSummaries return this.accountSummaries
} }
@@ -387,15 +397,8 @@ export class MicrosoftRewardsBot {
log('main', 'MAIN-PRIMARY', 'Primary process started') log('main', 'MAIN-PRIMARY', 'Primary process started')
const totalAccounts = this.accounts.length 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. // 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) const workerCount = Math.min(this.config.clusters, totalAccounts || 1)
const accountChunks = this.utils.chunkArray(this.accounts, workerCount) const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
// Reset activeWorkers to actual spawn count (constructor used raw clusters) // Reset activeWorkers to actual spawn count (constructor used raw clusters)
this.activeWorkers = workerCount this.activeWorkers = workerCount
@@ -403,13 +406,7 @@ export class MicrosoftRewardsBot {
for (let i = 0; i < workerCount; i++) { for (let i = 0; i < workerCount; i++) {
const worker = cluster.fork() const worker = cluster.fork()
const chunk = accountChunks[i] || [] 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) => { worker.on('message', (msg: unknown) => {
const m = msg as { type?: string; data?: AccountSummary[] } const m = msg as { type?: string; data?: AccountSummary[] }
if (m && m.type === 'summary' && Array.isArray(m.data)) { if (m && m.type === 'summary' && Array.isArray(m.data)) {
@@ -451,13 +448,8 @@ export class MicrosoftRewardsBot {
try { try {
await this.runAutoUpdate() await this.runAutoUpdate()
} catch {/* ignore */} } catch {/* ignore */}
// Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0) process.exit(0)
} else {
log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.')
}
})() })()
} }
}) })
@@ -544,33 +536,17 @@ export class MicrosoftRewardsBot {
} }
errors.push(formatFullErr('mobile', e)); return null errors.push(formatFullErr('mobile', e)); return null
}) })
const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise]) const [desktopResult, mobileResult] = await Promise.all([desktopPromise, mobilePromise])
if (desktopResult) {
// Handle desktop result desktopInitial = desktopResult.initialPoints
if (desktopResult.status === 'fulfilled' && desktopResult.value) { desktopCollected = desktopResult.collectedPoints
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) {
// Handle mobile result mobileInitial = mobileResult.initialPoints
if (mobileResult.status === 'fulfilled' && mobileResult.value) { mobileCollected = mobileResult.collectedPoints
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 {
// 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 { } else {
this.isMobile = false this.isMobile = false
this.isDesktopRunning = true
const desktopResult = await this.Desktop(account).catch(e => { const desktopResult = await this.Desktop(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
@@ -585,12 +561,10 @@ export class MicrosoftRewardsBot {
desktopInitial = desktopResult.initialPoints desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints desktopCollected = desktopResult.collectedPoints
} }
this.isDesktopRunning = false
// If banned or compromised detected, skip mobile to save time // If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) { if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true this.isMobile = true
this.isMobileRunning = true
const mobileResult = await this.Mobile(account).catch(e => { const mobileResult = await this.Mobile(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
@@ -605,13 +579,11 @@ export class MicrosoftRewardsBot {
mobileInitial = mobileResult.initialPoints mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints mobileCollected = mobileResult.collectedPoints
} }
this.isMobileRunning = false
} else { } else {
const why = banned.status ? 'banned status' : 'compromised status' const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn') log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
} }
} }
}
const accountEnd = Date.now() const accountEnd = Date.now()
const durationMs = accountEnd - accountStart const durationMs = accountEnd - accountStart
@@ -661,14 +633,10 @@ export class MicrosoftRewardsBot {
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open // If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
if (this.compromisedModeActive || this.globalStandby.active) { 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') 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 with cleanup on exit // Periodic heartbeat
const standbyInterval = setInterval(() => { setInterval(() => {
log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow') log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow')
}, 5 * 60 * 1000) }, 5 * 60 * 1000)
// Cleanup on process exit
process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) })
process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) })
return return
} }
// If in worker mode (clusters>1) send summaries to primary // If in worker mode (clusters>1) send summaries to primary
@@ -682,9 +650,11 @@ export class MicrosoftRewardsBot {
// Cleanup heartbeat timer/file at end of run // Cleanup heartbeat timer/file at end of run
if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } } if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } }
if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } } if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } }
// After conclusion, run optional auto-update // 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 */}) await this.runAutoUpdate().catch(() => {/* ignore update errors */})
} }
}
// Only exit if not spawned by scheduler // Only exit if not spawned by scheduler
if (!process.env.SCHEDULER_HEARTBEAT_FILE) { if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
process.exit() process.exit()
@@ -697,13 +667,17 @@ export class MicrosoftRewardsBot {
const h = this.config?.humanization const h = this.config?.humanization
if (!h || h.immediateBanAlert === false) return if (!h || h.immediateBanAlert === false) return
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook( const title = '🚫 Ban detected'
this.config, const desc = [`Account: ${email}`, `Reason: ${reason || 'detected by heuristics'}`].join('\n')
'🚫 Ban Detected', await ConclusionWebhook(this.config, `${title}\n${desc}`, {
`**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`, embeds: [
undefined, {
DISCORD.COLOR_RED title,
) description: desc,
color: DISCORD.COLOR_RED
}
]
})
} catch (e) { } catch (e) {
log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn') log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn')
} }
@@ -762,20 +736,19 @@ 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') 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 { try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook( await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Logged in successfully; leaving browser open. Security check by @Light`, {
this.config, context: 'compromised',
'🔐 Security Alert (Post-Login)', embeds: [
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks\n\n_Security check by @Light_`, {
undefined, title: '🔐 Security alert (post-login)',
0xFFAA00 description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving browser open; skipping tasks`,
) color: 0xFFAA00
}
]
})
} catch {/* ignore */} } catch {/* ignore */}
// Save session for convenience, but do not close the browser // Save session for convenience, but do not close the browser
try { try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
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 } return { initialPoints: 0, collectedPoints: 0 }
} }
@@ -866,19 +839,18 @@ 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') 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 { try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook( await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Mobile flow halted; leaving browser open. Security check by @Light`, {
this.config, context: 'compromised',
'🔐 Security Alert (Mobile)', embeds: [
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks\n\n_Security check by @Light_`, {
undefined, title: '🔐 Security alert (mobile)',
0xFFAA00 description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving mobile browser open; skipping tasks`,
) color: 0xFFAA00
} 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')
} }
]
})
} catch {/* ignore */}
try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
return { initialPoints: 0, collectedPoints: 0 } return { initialPoints: 0, collectedPoints: 0 }
} }
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email) this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
@@ -972,7 +944,7 @@ export class MicrosoftRewardsBot {
} }
private async sendConclusion(summaries: AccountSummary[]) { private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhookEnhanced } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const cfg = this.config const cfg = this.config
const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled) const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
@@ -987,7 +959,6 @@ export class MicrosoftRewardsBot {
let totalEnd = 0 let totalEnd = 0
let totalDuration = 0 let totalDuration = 0
let accountsWithErrors = 0 let accountsWithErrors = 0
let accountsBanned = 0
let successes = 0 let successes = 0
// Calculate summary statistics // Calculate summary statistics
@@ -996,9 +967,8 @@ export class MicrosoftRewardsBot {
totalInitial += s.initialTotal totalInitial += s.initialTotal
totalEnd += s.endTotal totalEnd += s.endTotal
totalDuration += s.durationMs totalDuration += s.durationMs
if (s.banned?.status) accountsBanned++
if (s.errors.length) accountsWithErrors++ if (s.errors.length) accountsWithErrors++
if (!s.banned?.status && !s.errors.length) successes++ else successes++
} }
const avgDuration = totalDuration / totalAccounts const avgDuration = totalDuration / totalAccounts
@@ -1015,23 +985,67 @@ export class MicrosoftRewardsBot {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Send enhanced webhook // Build clean embed with account details
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) { type DiscordField = { name: string; value: string; inline?: boolean }
await ConclusionWebhookEnhanced(cfg, { type DiscordEmbed = {
version, title?: string
runId: this.runId, description?: string
totalAccounts, color?: number
successes, fields?: DiscordField[]
accountsWithErrors, thumbnail?: { url: string }
accountsBanned, timestamp?: string
totalCollected, footer?: { text: string; icon_url?: string }
totalInitial, }
totalEnd,
avgPointsPerAccount, const accountDetails: string[] = []
totalDuration, for (const s of summaries) {
avgDuration, const statusIcon = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
summaries 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
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
} }
// Write local JSON report // Write local JSON report
@@ -1065,11 +1079,6 @@ export class MicrosoftRewardsBot {
log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn') 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). */ /** Reserve one diagnostics slot for this run (caps captures). */
@@ -1116,14 +1125,8 @@ export class MicrosoftRewardsBot {
if (upd.docker) args.push('--docker') if (upd.docker) args.push('--docker')
if (args.length === 0) return if (args.length === 0) return
// Pass scheduler flag to update script so it doesn't exit
const isSchedulerMode = !!process.env.SCHEDULER_HEARTBEAT_FILE
const env = isSchedulerMode
? { ...process.env, FROM_SCHEDULER: '1' }
: process.env
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit', env }) const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' })
child.on('close', () => resolve()) child.on('close', () => resolve())
child.on('error', () => resolve()) child.on('error', () => resolve())
}) })
@@ -1143,13 +1146,24 @@ export class MicrosoftRewardsBot {
private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> { private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> {
try { try {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
await ConclusionWebhook( const title = '🚨 Global security standby engaged'
this.config, const desc = [
'🚨 Global Security Standby Engaged', `Account: ${email}`,
`@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_`, `Reason: ${reason}`,
undefined, 'Action: Pausing all further accounts. We will not proceed until this is resolved.',
DISCORD.COLOR_RED '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
}
]
})
} catch (e) { } catch (e) {
log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn') log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn')
} }

View File

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

View File

@@ -67,8 +67,8 @@ export interface ConfigSearchDelay {
export interface ConfigWebhook { export interface ConfigWebhook {
enabled: boolean; enabled: boolean;
url: string; url: string;
username?: string; // Custom webhook username (default: "Microsoft Rewards") username?: string; // Optional override for displayed webhook name
avatarUrl?: string; // Custom webhook avatar URL avatarUrl?: string; // Optional avatar image URL
} }
export interface ConfigNtfy { export interface ConfigNtfy {
@@ -95,8 +95,6 @@ export interface ConfigUpdate {
git?: boolean; // if true, run git pull + npm ci + npm run build after completion 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 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 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 { export interface ConfigBuyMode {

View File

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

View File

@@ -202,11 +202,6 @@ 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') return lines.join('\n')
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,8 @@ import ms from 'ms'
export default class Util { export default class Util {
async wait(ms: number): Promise<void> { async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits
const MAX_WAIT_MS = 3600000 // 1 hour max
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) {
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
}
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(resolve, safeMs) setTimeout(resolve, ms)
}) })
} }
@@ -41,17 +33,7 @@ export default class Util {
} }
chunkArray<T>(arr: T[], numChunks: number): T[][] { chunkArray<T>(arr: T[], numChunks: number): T[][] {
// Validate input to prevent division by zero or invalid chunks const chunkSize = Math.ceil(arr.length / numChunks)
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[][] = [] const chunks: T[][] = []
for (let i = 0; i < arr.length; i += chunkSize) { for (let i = 0; i < arr.length; i += chunkSize) {
@@ -70,13 +52,4 @@ export default class Util {
return milisec 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 ''
}
}
} }