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

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

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

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

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

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

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

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

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

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

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

* feat: Add sample account setup

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update README.md

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

* Update README.md

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

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

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

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

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

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

* feat: update doc

* feat: update doc

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

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

* feat: update version to 2.2.7 in package.json

* feat: update version to 2.2.7 in README.md

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

* feat: refactor timeout and selector constants for improved maintainability

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

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

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

* feat: updated version to 2.3.0 and added new constants to improve the handling of delays and colors in logs

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

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

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

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

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

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

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

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

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

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

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

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

* fix: fix formatting of reinstallation message in README

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

* fix: remove backup instructions from installation guide in README

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

* fix: fix typographical error in README update notice

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

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

4
.gitignore vendored
View File

@@ -4,9 +4,13 @@ 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.3.0-blue?style=for-the-badge&logo=github&logoColor=white) ![Version](https://img.shields.io/badge/v2.4.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,14 +31,37 @@
<br> <br>
``` ---
╔══════════════════════════════════════════════════════════════════════════════╗
║ WHAT DOES THIS DO? ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
### 📌 **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.
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.
@@ -53,16 +76,11 @@ 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)
@@ -92,7 +110,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.json src/accounts.json cp src/accounts.example.jsonc src/accounts.json
# Edit accounts.json with your Microsoft credentials # Edit accounts.json with your Microsoft credentials
# 3. Install & build # 3. Install & build
@@ -104,11 +122,7 @@ npm start
<br> <br>
``` ## Intelligent Features
╔══════════════════════════════════════════════════════════════════════════════╗
║ INTELLIGENT FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<table> <table>
<tr> <tr>
@@ -159,11 +173,7 @@ Pre-flight checks
<br> <br>
``` ## Usage Commands
╔══════════════════════════════════════════════════════════════════════════════╗
║ USAGE COMMANDS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
```bash ```bash
# Run automation once # Run automation once
@@ -184,11 +194,7 @@ npm start -- --dry-run
<br> <br>
``` ## Configuration
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONFIGURATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
Edit `src/config.jsonc` to customize behavior: Edit `src/config.jsonc` to customize behavior:
@@ -220,11 +226,7 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
``` ## Core Features
╔══════════════════════════════════════════════════════════════════════════════╗
║ CORE FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -269,11 +271,7 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
``` ## Documentation
╔══════════════════════════════════════════════════════════════════════════════╗
║ DOCUMENTATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -290,11 +288,7 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
``` ## Technical Architecture
╔══════════════════════════════════════════════════════════════════════════════╗
║ TECHNICAL ARCHITECTURE ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -327,11 +321,7 @@ Edit `src/config.jsonc` to customize behavior:
<br> <br>
``` ## Important Disclaimers
╔══════════════════════════════════════════════════════════════════════════════╗
║ IMPORTANT DISCLAIMERS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -365,11 +355,7 @@ This project is for **educational purposes only**.
<br> <br>
``` ## Contributors
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONTRIBUTORS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -413,11 +399,7 @@ This project is for **educational purposes only**.
<br> <br>
``` ## Community & Support
╔══════════════════════════════════════════════════════════════════════════════╗
║ COMMUNITY & SUPPORT ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center"> <div align="center">
@@ -442,11 +424,13 @@ 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.
╔══════════════════════════════════════════════════════════════════════════════╗
║ LICENSE ║ </div>
╚══════════════════════════════════════════════════════════════════════════════╝
``` <br>
## License
<div align="center"> <div align="center">

View File

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

View File

@@ -1,148 +0,0 @@
# 🚀 Getting Started
**From zero to your first run in 10 minutes**
---
## ✅ Requirements
- **Node.js 20+** → [Download here](https://nodejs.org/)
- **Microsoft accounts** with email + password
- *Optional:* Docker for containers
---
## ⚡ Quick Setup (Recommended)
### Windows
```powershell
setup\setup.bat
```
### Linux / macOS
```bash
bash setup/setup.sh
```
### What Does It Do?
1. ✅ Asks for your Microsoft credentials
2. ✅ Creates `accounts.json` automatically
3. ✅ Installs dependencies
4. ✅ Builds the project
5. ✅ Runs your first automation (optional)
**That's it! 🎉**
---
## 🎯 After Installation
### 1⃣ Enable Scheduler (Recommended)
Run automatically once per day:
**Edit** `src/config.jsonc`:
```jsonc
{
"schedule": {
"enabled": true,
"time": "09:00",
"timeZone": "America/New_York"
}
}
```
**Start scheduler:**
```bash
npm run start:schedule
```
**[Full Scheduler Guide](./schedule.md)**
---
### 2⃣ Add Notifications (Optional)
Get a summary after each run:
```jsonc
{
"conclusionWebhook": {
"enabled": true,
"url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL"
}
}
```
**[Discord Setup](./conclusionwebhook.md)** | **[NTFY Setup](./ntfy.md)**
---
### 3⃣ Enable Humanization (Anti-Ban)
More natural behavior:
```jsonc
{
"humanization": {
"enabled": true
}
}
```
**[Humanization Guide](./humanization.md)**
---
## 🛠️ Common Issues
| Problem | Solution |
|---------|----------|
| **"Node.js not found"** | Install Node.js 20+ and restart terminal |
| **"accounts.json missing"** | Run `setup/setup.bat` or create manually |
| **"Login failed"** | Check email/password in `accounts.json` |
| **"2FA prompt"** | Add TOTP secret → [2FA Guide](./accounts.md) |
| **Script crashes** | Check [Diagnostics Guide](./diagnostics.md) |
---
## 🔧 Manual Setup (Advanced)
<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.3.0", "version": "2.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "microsoft-rewards-script", "name": "microsoft-rewards-script",
"version": "2.3.0", "version": "2.4.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.3.0", "version": "2.4.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/setup.mjs", "setup": "node ./setup/update/setup.mjs",
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", "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,19 +1,22 @@
@echo off @echo off
setlocal setlocal
REM Lightweight wrapper to run setup.mjs without prereq detection (Windows) REM Wrapper to run setup via npm (Windows)
REM Assumes Node is already installed and available in PATH. REM Navigates to project root and runs npm run setup
set SCRIPT_DIR=%~dp0 set SCRIPT_DIR=%~dp0
set SETUP_FILE=%SCRIPT_DIR%setup.mjs set PROJECT_ROOT=%SCRIPT_DIR%..
if not exist "%SETUP_FILE%" ( if not exist "%PROJECT_ROOT%\package.json" (
echo [ERROR] setup.mjs not found next to this batch file. echo [ERROR] package.json not found in project root.
pause pause
exit /b 1 exit /b 1
) )
echo Running setup script... echo Navigating to project root...
node "%SETUP_FILE%" cd /d "%PROJECT_ROOT%"
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

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

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.json -> accounts.json (idempotent) * - Renames accounts.example.jsonc -> 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 = parent of this setup directory // Project root = two levels up from setup/update 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.json'); const example = path.join(SRC_DIR, 'accounts.example.jsonc');
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.json to accounts.json...'); log('Renaming accounts.example.jsonc to accounts.json...');
fs.renameSync(example, accounts); fs.renameSync(example, accounts);
} else { } else {
warn('Neither accounts.json nor accounts.example.json found.'); warn('Neither accounts.json nor accounts.example.jsonc found.');
} }
} }

View File

@@ -1,19 +1,21 @@
/* eslint-disable linebreak-style */ /* eslint-disable linebreak-style */
/** /**
* Post-run auto-update script * Smart Auto-Update Script
* - If invoked with --git, runs: git fetch --all --prune; git pull --ff-only; npm ci; npm run build *
* - If invoked with --docker, runs: docker compose pull; docker compose up -d * Intelligently updates while preserving user settings:
* - ALWAYS updates code files (*.ts, *.js, etc.)
* - ONLY updates config.jsonc if remote has changes to it
* - ONLY updates accounts.json if remote has changes to it
* - KEEPS user passwords/emails/settings otherwise
* *
* Usage: * 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 } from 'node:child_process' import { spawn, execSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -25,20 +27,166 @@ 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]) const code = await run(probe, [cmd], { stdio: 'ignore' })
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) return 1 if (!hasGit) {
console.log('Git not found. Skipping update.')
return 1
}
console.log('\n' + '='.repeat(60))
console.log('Smart Git Update')
console.log('='.repeat(60))
// Step 1: Read config to get user preferences
let userConfig = { autoUpdateConfig: false, autoUpdateAccounts: false }
try {
if (existsSync('src/config.jsonc')) {
const configContent = readFileSync('src/config.jsonc', 'utf8')
.replace(/\/\/.*$/gm, '') // remove comments
.replace(/\/\*[\s\S]*?\*\//g, '') // remove multi-line comments
const config = JSON.parse(configContent)
if (config.update) {
userConfig.autoUpdateConfig = config.update.autoUpdateConfig ?? false
userConfig.autoUpdateAccounts = config.update.autoUpdateAccounts ?? false
}
}
} catch (e) {
console.log('Warning: Could not read config.jsonc, using defaults (preserve local files)')
}
console.log('\nUser preferences:')
console.log(` Auto-update config.jsonc: ${userConfig.autoUpdateConfig}`)
console.log(` Auto-update accounts.json: ${userConfig.autoUpdateAccounts}`)
// Step 2: Fetch
console.log('\nFetching latest changes...')
await run('git', ['fetch', '--all', '--prune']) await run('git', ['fetch', '--all', '--prune'])
const pullCode = await run('git', ['pull', '--ff-only'])
if (pullCode !== 0) return pullCode // Step 3: Get current branch
const currentBranch = exec('git branch --show-current')
if (!currentBranch) {
console.log('Could not determine current branch.')
return 1
}
// Step 4: Check which files changed in remote
const remoteBranch = `origin/${currentBranch}`
const filesChanged = exec(`git diff --name-only HEAD ${remoteBranch}`)
if (!filesChanged) {
console.log('Already up to date!')
return 0
}
const changedFiles = filesChanged.split('\n').filter(f => f.trim())
const configChanged = changedFiles.includes('src/config.jsonc')
const accountsChanged = changedFiles.includes('src/accounts.json')
// Step 5: ALWAYS backup config and accounts (smart strategy!)
const backupDir = join(process.cwd(), '.update-backup')
mkdirSync(backupDir, { recursive: true })
const filesToRestore = []
if (existsSync('src/config.jsonc')) {
console.log('\nBacking up config.jsonc...')
writeFileSync(join(backupDir, 'config.jsonc'), readFileSync('src/config.jsonc', 'utf8'))
// Restore if: remote changed it AND user doesn't want auto-update
if (configChanged && !userConfig.autoUpdateConfig) {
filesToRestore.push('config.jsonc')
}
}
if (existsSync('src/accounts.json')) {
console.log('Backing up accounts.json...')
writeFileSync(join(backupDir, 'accounts.json'), readFileSync('src/accounts.json', 'utf8'))
// Restore if: remote changed it AND user doesn't want auto-update
if (accountsChanged && !userConfig.autoUpdateAccounts) {
filesToRestore.push('accounts.json')
}
}
// Show what will happen
console.log('\nRemote changes:')
if (configChanged) {
console.log(` config.jsonc: ${userConfig.autoUpdateConfig ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`)
} else {
console.log(' config.jsonc: no changes in remote')
}
if (accountsChanged) {
console.log(` accounts.json: ${userConfig.autoUpdateAccounts ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`)
} else {
console.log(' accounts.json: no changes in remote')
}
// Step 6: Stash changes
const hasChanges = exec('git status --porcelain')
if (hasChanges) {
console.log('\nStashing local changes...')
await run('git', ['stash', 'push', '-m', 'Auto-update backup'])
}
// Step 7: Pull
console.log('\nPulling latest code...')
const pullCode = await run('git', ['pull', '--rebase'])
if (pullCode !== 0) {
console.log('Pull failed.')
if (hasChanges) await run('git', ['stash', 'pop'])
return pullCode
}
// Step 8: Restore files based on user preferences
if (filesToRestore.length > 0) {
console.log('\nRestoring local files (per your config preferences)...')
for (const file of filesToRestore) {
const content = readFileSync(join(backupDir, file), 'utf8')
writeFileSync(join('src', file), content)
console.log(` ✓ Restored ${file}`)
}
}
// Step 9: Restore stash
if (hasChanges) {
await run('git', ['stash', 'pop'])
}
// Step 9: Install & build
const hasNpm = await which('npm') 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() {
@@ -61,7 +209,17 @@ async function main() {
if (doDocker && code === 0) { if (doDocker && code === 0) {
code = await updateDocker() code = await updateDocker()
} }
process.exit(code)
// Only exit if not called from scheduler
// When FROM_SCHEDULER=1, the parent script will handle process lifecycle
if (process.env.FROM_SCHEDULER !== '1') {
process.exit(code)
}
} }
main().catch(() => process.exit(1)) main().catch(() => {
// Only exit on error if not called from scheduler
if (process.env.FROM_SCHEDULER !== '1') {
process.exit(1)
}
})

View File

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

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

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

View File

@@ -54,7 +54,8 @@ class Browser {
const engineName = 'chromium' // current hard-coded engine 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({
//channel: 'msedge', // Uses Edge instead of chrome // Optional: uncomment to use Edge instead of Chromium
// 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: [
@@ -70,7 +71,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: "npx playwright install chromium" (or set AUTO_INSTALL_BROWSERS=1 to auto attempt).', 'error') this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error')
} else { } 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,8 +45,9 @@
"runOnZeroPoints": false, "runOnZeroPoints": false,
// Number of account clusters (processes) to run concurrently // Number of account clusters (processes) to run concurrently
"clusters": 1, "clusters": 1,
// Number of passes per invocation (usually 1) // How many times to run through all accounts in sequence (1 = process each account once, 2 = twice, etc.)
"passesPerRun": 1 // Higher values can catch missed tasks but increase detection risk
"passesPerRun": 3
}, },
"schedule": { "schedule": {
@@ -207,12 +208,18 @@
// 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": {
@@ -293,6 +300,44 @@
"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,5 +63,12 @@ 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,7 +28,14 @@ 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: Number(process.env.LOGIN_MAX_WAIT_MS || 180000), // 3 min loginMaxMs: (() => {
const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000)
if (isNaN(val) || val < 10000 || val > 600000) {
console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 180000ms`)
return 180000
}
return val
})(),
short: 500, short: 500,
medium: 1500, medium: 1500,
long: 3000 long: 3000
@@ -71,6 +78,12 @@ 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
@@ -285,40 +298,45 @@ 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
const inputPromise = new Promise<string>(res => { try {
rl.question('Enter 2FA code:\n', ans => { const inputPromise = new Promise<string>(res => {
if (checkInterval) clearInterval(checkInterval) rl.question('Enter 2FA code:\n', ans => {
rl.close()
res(ans.trim())
})
})
// Check every 2 seconds if user manually progressed past the dialog
checkInterval = setInterval(async () => {
try {
await this.bot.browser.utils.tryDismissAllMessages(page)
// Check if we're no longer on 2FA page
const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
if (!still2FA) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
if (checkInterval) clearInterval(checkInterval) if (checkInterval) clearInterval(checkInterval)
rl.close() rl.close()
userInput = 'skip' // Signal to skip submission res(ans.trim())
} })
} catch {/* ignore */} })
}, 2000)
const code = await inputPromise // Check every 2 seconds if user manually progressed past the dialog
if (checkInterval) clearInterval(checkInterval) checkInterval = setInterval(async () => {
try {
if (code === 'skip' || userInput === 'skip') { await this.bot.browser.utils.tryDismissAllMessages(page)
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)') // Check if we're no longer on 2FA page
return const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
if (!still2FA) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
if (checkInterval) clearInterval(checkInterval)
rl.close()
userInput = 'skip' // Signal to skip submission
}
} catch {/* ignore */}
}, 2000)
const code = await inputPromise
if (code === 'skip' || userInput === 'skip') {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)')
return
}
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
} finally {
// Ensure cleanup happens even if errors occur
if (checkInterval) clearInterval(checkInterval)
try { rl.close() } catch {/* ignore */}
} }
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
} }
private async ensureTotpInput(page: Page): Promise<string | null> { private async ensureTotpInput(page: Page): Promise<string | null> {
@@ -758,12 +776,19 @@ export class Login {
this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level) this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level)
try { try {
const { ConclusionWebhook } = await import('../util/ConclusionWebhook') const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
await ConclusionWebhook(this.bot.config,'', { embeds:[{ title:`🔐 ${incident.kind}`, description:'Security check by @Light', color: severity==='critical'?0xFF0000:0xFFAA00, fields:[ const 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,6 +43,9 @@ 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
@@ -185,24 +188,13 @@ 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')
const title = '💳 Spend detected (Buy Mode)' await ConclusionWebhook(
const desc = [ this.config,
`Account: ${account.email}`, '💳 Spend Detected',
`Spent: -${delta} points`, `**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`,
`Current: ${nowPts} points`, undefined,
`Session spent: ${cumulativeSpent} points` 0xFFAA00
].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')
} }
@@ -261,7 +253,11 @@ 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 { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch { /* ignore */ } try {
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
} catch (e) {
log(false, 'BUY-MODE', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
try { if (!monitor.isClosed()) await monitor.close() } catch {/* ignore */} 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
@@ -318,19 +314,23 @@ export class MicrosoftRewardsBot {
╚══════════════════════════════════════════════════════╝ ╚══════════════════════════════════════════════════════╝
` `
// Read package version and build banner info
const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown'
try { try {
const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown'
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 {
// Show appropriate banner based on mode // Ignore version read errors
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner }
console.log(displayBanner)
console.log('='.repeat(80)) // Display appropriate banner based on mode
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner)
console.log('='.repeat(80))
if (this.buyMode.enabled) { if (this.buyMode.enabled) {
console.log(` Version: ${version} | Process: ${process.pid} | Buy Mode: Active`) console.log(` Version: ${version} | Process: ${process.pid} | Buy Mode: Active`)
@@ -376,19 +376,9 @@ export class MicrosoftRewardsBot {
} }
} }
console.log('='.repeat(80) + '\n') console.log('='.repeat(80) + '\n')
} catch { }
const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
console.log(displayBanner) // Return summaries (used when clusters==1)
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)
public getSummaries() { public getSummaries() {
return this.accountSummaries return this.accountSummaries
} }
@@ -397,8 +387,15 @@ 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 || 1) const workerCount = Math.min(this.config.clusters, totalAccounts)
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
@@ -406,7 +403,13 @@ 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)) {
@@ -448,8 +451,13 @@ export class MicrosoftRewardsBot {
try { try {
await this.runAutoUpdate() await this.runAutoUpdate()
} catch {/* ignore */} } catch {/* ignore */}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') // Only exit if not spawned by scheduler
process.exit(0) if (!process.env.SCHEDULER_HEARTBEAT_FILE) {
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
} else {
log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.')
}
})() })()
} }
}) })
@@ -536,52 +544,72 @@ export class MicrosoftRewardsBot {
} }
errors.push(formatFullErr('mobile', e)); return null errors.push(formatFullErr('mobile', e)); return null
}) })
const [desktopResult, mobileResult] = await Promise.all([desktopPromise, mobilePromise]) const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
if (desktopResult) {
desktopInitial = desktopResult.initialPoints // Handle desktop result
desktopCollected = desktopResult.collectedPoints if (desktopResult.status === 'fulfilled' && desktopResult.value) {
desktopInitial = desktopResult.value.initialPoints
desktopCollected = desktopResult.value.collectedPoints
} else if (desktopResult.status === 'rejected') {
log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error')
errors.push(formatFullErr('desktop-rejected', desktopResult.reason))
} }
if (mobileResult) {
mobileInitial = mobileResult.initialPoints // Handle mobile result
mobileCollected = mobileResult.collectedPoints if (mobileResult.status === 'fulfilled' && mobileResult.value) {
mobileInitial = mobileResult.value.initialPoints
mobileCollected = mobileResult.value.collectedPoints
} else if (mobileResult.status === 'rejected') {
log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error')
errors.push(formatFullErr('mobile-rejected', mobileResult.reason))
} }
} else { } else {
this.isMobile = false // Sequential execution with safety checks
const desktopResult = await this.Desktop(account).catch(e => { if (this.isDesktopRunning || this.isMobileRunning) {
const msg = e instanceof Error ? e.message : String(e) log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error')
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') errors.push('race-condition-detected')
const bd = detectBanReason(e) } else {
if (bd.status) { this.isMobile = false
banned.status = true; banned.reason = bd.reason.substring(0,200) this.isDesktopRunning = true
void this.handleImmediateBanAlert(account.email, banned.reason) const desktopResult = await this.Desktop(account).catch(e => {
}
errors.push(formatFullErr('desktop', e)); return null
})
if (desktopResult) {
desktopInitial = desktopResult.initialPoints
desktopCollected = desktopResult.collectedPoints
}
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
const mobileResult = await this.Mobile(account).catch(e => {
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(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e) const bd = detectBanReason(e)
if (bd.status) { if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200) banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason) void this.handleImmediateBanAlert(account.email, banned.reason)
} }
errors.push(formatFullErr('mobile', e)); return null errors.push(formatFullErr('desktop', e)); return null
}) })
if (mobileResult) { if (desktopResult) {
mobileInitial = mobileResult.initialPoints desktopInitial = desktopResult.initialPoints
mobileCollected = mobileResult.collectedPoints desktopCollected = desktopResult.collectedPoints
}
this.isDesktopRunning = false
// If banned or compromised detected, skip mobile to save time
if (!banned.status && !this.compromisedModeActive) {
this.isMobile = true
this.isMobileRunning = true
const mobileResult = await this.Mobile(account).catch(e => {
const msg = e instanceof Error ? e.message : String(e)
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
const bd = detectBanReason(e)
if (bd.status) {
banned.status = true; banned.reason = bd.reason.substring(0,200)
void this.handleImmediateBanAlert(account.email, banned.reason)
}
errors.push(formatFullErr('mobile', e)); return null
})
if (mobileResult) {
mobileInitial = mobileResult.initialPoints
mobileCollected = mobileResult.collectedPoints
}
this.isMobileRunning = false
} else {
const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
} }
} else {
const why = banned.status ? 'banned status' : 'compromised status'
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
} }
} }
@@ -633,10 +661,14 @@ export class MicrosoftRewardsBot {
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open // If 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 // Periodic heartbeat with cleanup on exit
setInterval(() => { const standbyInterval = 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
@@ -650,10 +682,8 @@ 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 (only if not in scheduler mode) // After conclusion, run optional auto-update
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) {
@@ -667,17 +697,13 @@ 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')
const title = '🚫 Ban detected' await ConclusionWebhook(
const desc = [`Account: ${email}`, `Reason: ${reason || 'detected by heuristics'}`].join('\n') this.config,
await ConclusionWebhook(this.config, `${title}\n${desc}`, { '🚫 Ban Detected',
embeds: [ `**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`,
{ undefined,
title, DISCORD.COLOR_RED
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')
} }
@@ -736,19 +762,20 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') 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(this.config, `Security issue on ${account.email} (${reason}). Logged in successfully; leaving browser open. Security check by @Light`, { await ConclusionWebhook(
context: 'compromised', this.config,
embeds: [ '🔐 Security Alert (Post-Login)',
{ `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks\n\n_Security check by @Light_`,
title: '🔐 Security alert (post-login)', undefined,
description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving browser open; skipping tasks`, 0xFFAA00
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 { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ } try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 } return { initialPoints: 0, collectedPoints: 0 }
} }
@@ -839,18 +866,19 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') 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(this.config, `Security issue on ${account.email} (${reason}). Mobile flow halted; leaving browser open. Security check by @Light`, { await ConclusionWebhook(
context: 'compromised', this.config,
embeds: [ '🔐 Security Alert (Mobile)',
{ `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks\n\n_Security check by @Light_`,
title: '🔐 Security alert (mobile)', undefined,
description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving mobile browser open; skipping tasks`, 0xFFAA00
color: 0xFFAA00 )
}
]
})
} catch {/* ignore */} } catch {/* ignore */}
try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ } try {
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
} catch (e) {
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
}
return { initialPoints: 0, collectedPoints: 0 } 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)
@@ -944,7 +972,7 @@ export class MicrosoftRewardsBot {
} }
private async sendConclusion(summaries: AccountSummary[]) { private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const { ConclusionWebhookEnhanced } = 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)
@@ -959,6 +987,7 @@ 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
@@ -967,8 +996,9 @@ 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++
else successes++ if (!s.banned?.status && !s.errors.length) successes++
} }
const avgDuration = totalDuration / totalAccounts const avgDuration = totalDuration / totalAccounts
@@ -985,67 +1015,23 @@ export class MicrosoftRewardsBot {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// Build clean embed with account details // Send enhanced webhook
type DiscordField = { name: string; value: string; inline?: boolean }
type DiscordEmbed = {
title?: string
description?: string
color?: number
fields?: DiscordField[]
thumbnail?: { url: string }
timestamp?: string
footer?: { text: string; icon_url?: string }
}
const accountDetails: string[] = []
for (const s of summaries) {
const statusIcon = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
const line = `${statusIcon} **${s.email}** → +${s.totalCollected}pts (🖥️${s.desktopCollected} 📱${s.mobileCollected}) • ${formatDuration(s.durationMs)}`
accountDetails.push(line)
if (s.banned?.status) accountDetails.push(` └ Banned: ${s.banned.reason || 'detected'}`)
if (s.errors.length > 0) accountDetails.push(` └ Errors: ${s.errors.slice(0, 2).join(', ')}`)
}
const embed: DiscordEmbed = {
title: '🎯 Microsoft Rewards - Daily Summary',
description: [
'**📊 Global Statistics**',
`├ Total Points: **${totalInitial}** → **${totalEnd}** (+**${totalCollected}**)`,
`├ Accounts: ✅ ${successes}${accountsWithErrors > 0 ? `⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`,
`├ Average: **${avgPointsPerAccount}pts/account** • **${formatDuration(avgDuration)}/account**`,
`└ Runtime: **${formatDuration(totalDuration)}**`,
'',
'**📈 Account Details**',
...accountDetails
].filter(Boolean).join('\n'),
color: accountsWithErrors > 0 ? DISCORD.COLOR_ORANGE : DISCORD.COLOR_GREEN,
thumbnail: {
url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
},
timestamp: new Date().toISOString(),
footer: {
text: `MS Rewards Bot v${version} • Run ${this.runId}`,
icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
}
}
// NTFY plain text fallback
const fallback = [
'🎯 Microsoft Rewards Summary',
`Accounts: ${totalAccounts} (✅${successes} ${accountsWithErrors > 0 ? `⚠️${accountsWithErrors}` : ''})`,
`Total: ${totalInitial}${totalEnd} (+${totalCollected})`,
`Average: ${avgPointsPerAccount}pts/account • ${formatDuration(avgDuration)}`,
`Runtime: ${formatDuration(totalDuration)}`,
'',
...summaries.map(s => {
const st = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : '✅')
return `${st} ${s.email}: +${s.totalCollected}pts (🖥️${s.desktopCollected} 📱${s.mobileCollected})`
})
].join('\n')
// Send webhook
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) { if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
await ConclusionWebhook(cfg, fallback, { embeds: [embed] }) await ConclusionWebhookEnhanced(cfg, {
version,
runId: this.runId,
totalAccounts,
successes,
accountsWithErrors,
accountsBanned,
totalCollected,
totalInitial,
totalEnd,
avgPointsPerAccount,
totalDuration,
avgDuration,
summaries
})
} }
// Write local JSON report // Write local JSON report
@@ -1079,6 +1065,11 @@ 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). */
@@ -1125,8 +1116,14 @@ 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' }) const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit', env })
child.on('close', () => resolve()) child.on('close', () => resolve())
child.on('error', () => resolve()) child.on('error', () => resolve())
}) })
@@ -1146,24 +1143,13 @@ 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')
const title = '🚨 Global security standby engaged' await ConclusionWebhook(
const desc = [ this.config,
`Account: ${email}`, '🚨 Global Security Standby Engaged',
`Reason: ${reason}`, `@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_`,
'Action: Pausing all further accounts. We will not proceed until this is resolved.', undefined,
'Security check by @Light' DISCORD.COLOR_RED
].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,4 +1,6 @@
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; // Optional override for displayed webhook name username?: string; // Custom webhook username (default: "Microsoft Rewards")
avatarUrl?: string; // Optional avatar image URL avatarUrl?: string; // Custom webhook avatar URL
} }
export interface ConfigNtfy { export interface ConfigNtfy {
@@ -95,6 +95,8 @@ 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,6 +13,12 @@ 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') {
@@ -114,13 +120,28 @@ 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 staleHeartbeatMin = Number( const parseEnvNumber = (key: string, fallback: number, min: number, max: number): number => {
process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES || process.env.SCHEDULER_PASS_TIMEOUT_MINUTES || 30 const val = Number(process.env[key] || fallback)
if (isNaN(val) || val < min || val > max) {
void log('main', 'SCHEDULER', `Invalid ${key}="${process.env[key]}". Using default ${fallback}`, 'warn')
return fallback
}
return val
}
const staleHeartbeatMin = parseEnvNumber(
process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES ? 'SCHEDULER_STALE_HEARTBEAT_MINUTES' : 'SCHEDULER_PASS_TIMEOUT_MINUTES',
30, 5, 1440
) )
const graceMin = Number(process.env.SCHEDULER_HEARTBEAT_GRACE_MINUTES || 15) const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120)
const hardcapMin = Number(process.env.SCHEDULER_PASS_HARDCAP_MINUTES || 480) // 8 hours const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440)
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'
@@ -147,6 +168,8 @@ 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')
@@ -162,7 +185,8 @@ 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')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
return return
} }
// Before grace, don't judge // Before grace, don't judge
@@ -175,19 +199,23 @@ 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')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
} }
} catch { } catch (err) {
// If file missing after grace, consider stale // If file missing after grace, consider stale
log('main', 'SCHEDULER', 'Heartbeat file missing after grace. Terminating child...', 'warn') const msg = err instanceof Error ? err.message : String(err)
log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn')
void killChild('SIGTERM') void killChild('SIGTERM')
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) if (killTimeout) clearTimeout(killTimeout)
killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
} }
}, checkEveryMs) }, 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) {
@@ -203,6 +231,7 @@ 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()
@@ -286,9 +315,21 @@ 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 initJitterMin = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MIN || process.env.SCHEDULER_INITIAL_JITTER_MIN || 0) const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => {
const initJitterMax = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MAX || process.env.SCHEDULER_INITIAL_JITTER_MAX || 0) const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0)
const initialJitterBounds: [number, number] = [isFinite(initJitterMin) ? initJitterMin : 0, isFinite(initJitterMax) ? initJitterMax : 0] const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0)
if (isNaN(minVal) || minVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn')
return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal]
}
if (isNaN(maxVal) || maxVal < 0) {
void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn')
return [minVal, 0]
}
return [minVal, maxVal]
}
const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX')
const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0) const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0)
if (runImmediate && !running) { if (runImmediate && !running) {
@@ -327,10 +368,9 @@ 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 dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0) const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX')
const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0) const djMin = dailyJitterBounds[0]
const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0 const djMax = dailyJitterBounds[1]
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))
@@ -373,6 +413,6 @@ async function main() {
} }
main().catch((e) => { main().catch((e) => {
console.error(e) void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error')
process.exit(1) process.exit(1)
}) })

View File

@@ -202,6 +202,11 @@ export class Analytics {
) )
} }
// Optional community info footer (only in markdown exports)
lines.push('')
lines.push('---')
lines.push('*Looking for faster updates, advanced features, and better anti-detection? Join our community for enhanced versions and support.*')
return lines.join('\n') 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'): case proxyConfig.url.startsWith('socks://') || proxyConfig.url.startsWith('socks4://') || proxyConfig.url.startsWith('socks5://'):
return new SocksProxyAgent(`${url}:${port}`) return new SocksProxyAgent(`${url}:${port}`)
default: default:
throw new Error(`Unsupported proxy protocol: ${url}`) throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`)
} }
} }
@@ -42,29 +42,54 @@ class AxiosClient {
return bypassInstance.request(config) return bypassInstance.request(config)
} }
try { let lastError: unknown
return await this.instance.request(config) const maxAttempts = 2
} catch (err: unknown) {
const axiosErr = err as AxiosError | undefined for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await this.instance.request(config)
} catch (err: unknown) {
lastError = err
const axiosErr = err as AxiosError | undefined
// Detect HTTP proxy auth failures (status 407) and retry without proxy once. // Detect HTTP proxy auth failures (status 407) and retry without proxy
if (!bypassProxy && axiosErr && axiosErr.response && axiosErr.response.status === 407) { if (axiosErr && axiosErr.response && axiosErr.response.status === 407) {
const bypassInstance = axios.create() if (attempt < maxAttempts) {
return bypassInstance.request(config) await this.sleep(1000 * attempt) // Exponential backoff
} }
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// If proxied request fails with common proxy/network errors, retry once without proxy // If proxied request fails with common proxy/network errors, retry with backoff
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined const 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)) {
const bypassInstance = axios.create() if (isNetErr || looksLikeProxyIssue) {
return bypassInstance.request(config) if (attempt < maxAttempts) {
// Exponential backoff: 1s, 2s, 4s, etc.
const delayMs = 1000 * Math.pow(2, attempt - 1)
await this.sleep(delayMs)
continue
}
// Last attempt: try without proxy
const bypassInstance = axios.create()
return bypassInstance.request(config)
}
// Non-retryable error
throw err
} }
throw err
} }
throw lastError
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
} }
} }

View File

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

View File

@@ -470,6 +470,7 @@ export class ConfigValidator {
/** /**
* Print validation results to console with color * 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,12 +215,18 @@ 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(__dirname, file) // dist/accounts.json (legacy) path.join(process.cwd(), 'src', file + 'c'), // cwd/src/accounts.jsonc
path.join(__dirname, file), // dist/accounts.json (legacy)
path.join(__dirname, file + 'c') // dist/accounts.jsonc
] ]
let chosen: string | null = null let chosen: string | null = null
for (const p of candidates) { for (const p of candidates) {
@@ -242,7 +248,10 @@ 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')
} }
} }
return parsed as Account[] // Filter out disabled accounts (enabled: false)
const allAccounts = parsed as Account[]
const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false)
return enabledAccounts
} catch (error) { } catch (error) {
throw new Error(error as string) throw new Error(error as string)
} }

View File

@@ -5,9 +5,7 @@ import { Ntfy } from './Ntfy'
import { loadConfig } from './Load' import { loadConfig } from './Load'
import { DISCORD } from '../constants' import { DISCORD } from '../constants'
// Avatar URL for webhook (consistent with ConclusionWebhook) const DEFAULT_LIVE_LOG_USERNAME = 'MS Rewards - Live Logs'
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[]
@@ -17,18 +15,41 @@ 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
@@ -48,8 +69,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: WEBHOOK_USERNAME, username: webhookUsername,
avatar_url: WEBHOOK_AVATAR_URL, avatar_url: webhookAvatarUrl,
embeds: [{ embeds: [{
description: `\`\`\`\n${content}\n\`\`\``, description: `\`\`\`\n${content}\n\`\`\``,
color: determineColorFromContent(content), color: determineColorFromContent(content),

View File

@@ -172,9 +172,10 @@ 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()
const timeout = setTimeout(() => controller.abort(), 10000) timeoutHandle = 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',
@@ -182,13 +183,15 @@ async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionRes
}, },
signal: controller.signal signal: controller.signal
}) })
clearTimeout(timeout) clearTimeout(timeoutHandle)
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,8 +3,16 @@ 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, ms) setTimeout(resolve, safeMs)
}) })
} }
@@ -33,7 +41,17 @@ export default class Util {
} }
chunkArray<T>(arr: T[], numChunks: number): T[][] { chunkArray<T>(arr: T[], numChunks: number): T[][] {
const chunkSize = Math.ceil(arr.length / numChunks) // Validate input to prevent division by zero or invalid chunks
if (numChunks <= 0) {
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`)
}
if (arr.length === 0) {
return []
}
const safeNumChunks = Math.max(1, Math.floor(numChunks))
const chunkSize = Math.ceil(arr.length / safeNumChunks)
const chunks: T[][] = [] const chunks: T[][] = []
for (let i = 0; i < arr.length; i += chunkSize) { for (let i = 0; i < arr.length; i += chunkSize) {
@@ -52,4 +70,13 @@ 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 ''
}
}
} }