From 2a8ab7242f8e47b7662544a84d8110468535590f Mon Sep 17 00:00:00 2001 From: Netsky <56271887+TheNetsky@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:36:09 +0200 Subject: [PATCH] Revert "V2.4.0 (#381)" (#392) This reverts commit f2d00225c95b36a7f0915aa6a7c07a8cbe38b393. --- .gitignore | 4 - README.md | 110 ++++++---- docs/conclusionwebhook.md | 129 +++-------- docs/getting-started.md | 148 +++++++++++++ package-lock.json | 4 +- package.json | 4 +- setup/setup.bat | 17 +- setup/{update => }/setup.mjs | 12 +- setup/setup.sh | 32 +-- setup/update/update.mjs | 188 ++-------------- src/accounts.example.json | 31 +++ src/accounts.example.jsonc | 151 ------------- src/browser/Browser.ts | 5 +- src/config.jsonc | 55 +---- src/constants.ts | 9 +- src/functions/Login.ts | 99 ++++----- src/index.ts | 382 ++++++++++++++++---------------- src/interface/Account.ts | 2 - src/interface/Config.ts | 6 +- src/scheduler.ts | 74 ++----- src/util/Analytics.ts | 5 - src/util/Axios.ts | 73 ++----- src/util/ConclusionWebhook.ts | 400 +++++++--------------------------- src/util/ConfigValidator.ts | 1 - src/util/Load.ts | 13 +- src/util/Logger.ts | 31 +-- src/util/UserAgent.ts | 7 +- src/util/Utils.ts | 31 +-- 28 files changed, 690 insertions(+), 1333 deletions(-) create mode 100644 docs/getting-started.md rename setup/{update => }/setup.mjs (95%) create mode 100644 src/accounts.example.json delete mode 100644 src/accounts.example.jsonc diff --git a/.gitignore b/.gitignore index af3a92c..a4ee868 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,9 @@ node_modules/ .vscode/ .github/ diagnostic/ -report/ accounts.json -accounts.jsonc notes accounts.dev.json -accounts.dev.jsonc accounts.main.json -accounts.main.jsonc .DS_Store .playwright-chromium-installed \ No newline at end of file diff --git a/README.md b/README.md index edac709..88742cf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@
-![Version](https://img.shields.io/badge/v2.4.0-blue?style=for-the-badge&logo=github&logoColor=white) +![Version](https://img.shields.io/badge/v2.3.0-blue?style=for-the-badge&logo=github&logoColor=white) ![License](https://img.shields.io/badge/ISC-00D9FF?style=for-the-badge) ![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) @@ -31,36 +31,13 @@
---- - -
- -### πŸ“Œ **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 +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ WHAT DOES THIS DO? β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ``` -⚠️ 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. - -
- ---- - -
- -## What Does This Do? +
**Automate your Microsoft Rewards daily activities with intelligent browser automation.** Complete searches, quizzes, and promotions automatically while mimicking natural human behavior. @@ -76,11 +53,16 @@ Complete searches, quizzes, and promotions automatically while mimicking natural | **Daily Set Tasks** | ~30-50 pts | 1-2 min | | **Promotions & Punch Cards** | Variable | 30s-2min | | **πŸ“Š TOTAL AVERAGE** | **150-300+ pts** | **3-5 min** | +

-## Quick Start +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ QUICK START β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` ### **πŸš€ Automated Setup** (Recommended) @@ -110,7 +92,7 @@ git clone -b v2 https://github.com/TheNetsky/Microsoft-Rewards-Script.git cd Microsoft-Rewards-Script # 2. Configure accounts -cp src/accounts.example.jsonc src/accounts.json +cp src/accounts.example.json src/accounts.json # Edit accounts.json with your Microsoft credentials # 3. Install & build @@ -122,7 +104,11 @@ npm start
-## Intelligent Features +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ INTELLIGENT FEATURES β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` @@ -173,7 +159,11 @@ Pre-flight checks
-## Usage Commands +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ USAGE COMMANDS β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` ```bash # Run automation once @@ -194,7 +184,11 @@ npm start -- --dry-run
-## Configuration +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ CONFIGURATION β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +``` Edit `src/config.jsonc` to customize behavior: @@ -226,7 +220,11 @@ Edit `src/config.jsonc` to customize behavior:
-## Core Features +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ CORE FEATURES β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -271,7 +269,11 @@ Edit `src/config.jsonc` to customize behavior:
-## Documentation +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ DOCUMENTATION β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -288,7 +290,11 @@ Edit `src/config.jsonc` to customize behavior:
-## Technical Architecture +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ TECHNICAL ARCHITECTURE β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -321,7 +327,11 @@ Edit `src/config.jsonc` to customize behavior:
-## Important Disclaimers +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ IMPORTANT DISCLAIMERS β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -355,7 +365,11 @@ This project is for **educational purposes only**.
-## Contributors +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ CONTRIBUTORS β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -399,7 +413,11 @@ This project is for **educational purposes only**.
-## Community & Support +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ COMMUNITY & SUPPORT β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
@@ -424,13 +442,11 @@ GitHub Issues are also available for documentation and tracking.
-> πŸ’‘ **Looking for enhanced builds?** Community-maintained versions with faster updates and advanced features may be available. Ask in our Discord for more info. - -
- -
- -## License +``` +╔══════════════════════════════════════════════════════════════════════════════╗ +β•‘ LICENSE β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +```
diff --git a/docs/conclusionwebhook.md b/docs/conclusionwebhook.md index 92f1339..7ca46ae 100644 --- a/docs/conclusionwebhook.md +++ b/docs/conclusionwebhook.md @@ -1,18 +1,16 @@ # πŸ“Š Discord Webhooks -**Get beautiful run summaries in Discord** +**Get run summaries in Discord** --- ## πŸ’‘ What Is It? -Sends a **professional, rich embed** to your Discord server after each run with: -- πŸ“Š **Total accounts processed** with success/warning/banned breakdown -- πŸ’Ž **Points earned** β€” clear before/after comparison -- ⚑ **Performance metrics** β€” average points and execution time -- πŸ“ˆ **Per-account breakdown** β€” detailed stats for each account -- 🎨 **Beautiful formatting** β€” color-coded status, emojis, and clean layout -- ⏱️ **Timestamp** and version info in footer +Sends a **rich embed** to your Discord server after each run with: +- πŸ“Š Total accounts processed +- πŸ’Ž Points earned +- ⏱️ Execution time +- ❌ Errors encountered --- @@ -33,10 +31,7 @@ Sends a **professional, rich embed** to your Discord server after each run with: "notifications": { "conclusionWebhook": { "enabled": true, - "url": "https://discord.com/api/webhooks/123456789/abcdef-your-webhook-token", - // Optional: Customize webhook appearance - "username": "Microsoft Rewards", - "avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png" + "url": "https://discord.com/api/webhooks/123456789/abcdef-your-webhook-token" } } } @@ -48,51 +43,30 @@ Sends a **professional, rich embed** to your Discord server after each run with: ## πŸ“‹ Example Summary -The new webhook format provides a **clean, professional Discord embed** with: - -### Main Summary Card ``` -🎯 Microsoft Rewards β€” Daily Summary +🎯 Microsoft Rewards Summary -Status: βœ… Success -Version: v2.4.0 β€’ Run ID: abc123xyz -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +πŸ“Š Accounts: 3 β€’ 0 with issues +πŸ’Ž Points: 15,230 β†’ 16,890 (+1,660) +⏱️ Average Duration: 8m 32s +πŸ“ˆ Cumulative Runtime: 25m 36s -πŸ“Š Global Statistics -πŸ’Ž Total Points Earned -15,230 β†’ 16,890 (+1,660) +πŸ‘€ user1@example.com +Points: 5,420 β†’ 6,140 (+720) +Duration: 7m 23s +Status: βœ… Completed successfully -πŸ“Š Accounts Processed -βœ… Success: 3 | ⚠️ Errors: 0 | 🚫 Banned: 0 -Total: 3 accounts +πŸ‘€ user2@example.com +Points: 4,810 β†’ 5,750 (+940) +Duration: 9m 41s +Status: βœ… Completed successfully -⚑ Performance -Average: 553pts/account in 8m 32s -Total Runtime: 25m 36s +πŸ‘€ user3@example.com +Points: 5,000 β†’ 5,000 (+0) +Duration: 8m 32s +Status: βœ… Completed successfully ``` -### Per-Account Details -``` -οΏ½ Account Details - -βœ… user1@example.com -β”” Points: +720 (πŸ–₯️ 450 β€’ πŸ“± 270) -β”” Duration: 7m 23s - -βœ… user2@example.com -β”” Points: +940 (πŸ–₯️ 540 β€’ πŸ“± 400) -β”” Duration: 9m 41s - -βœ… user3@example.com -β”” Points: +0 (πŸ–₯️ 0 β€’ πŸ“± 0) -β”” Duration: 8m 32s -``` - -**Color coding:** -- 🟒 **Green** β€” All accounts successful -- 🟠 **Orange** β€” Some accounts with errors -- πŸ”΄ **Red** β€” Banned accounts detected - --- ## 🎯 Advanced: Separate Channels @@ -104,15 +78,11 @@ Use different webhooks for different notifications: "notifications": { "webhook": { "enabled": true, - "url": "https://discord.com/api/webhooks/.../errors-channel", - "username": "Rewards Errors", - "avatarUrl": "https://example.com/error-icon.png" + "url": "https://discord.com/api/webhooks/.../errors-channel" }, "conclusionWebhook": { "enabled": true, - "url": "https://discord.com/api/webhooks/.../summary-channel", - "username": "Rewards Summary", - "avatarUrl": "https://example.com/success-icon.png" + "url": "https://discord.com/api/webhooks/.../summary-channel" } } } @@ -121,53 +91,6 @@ Use different webhooks for different notifications: - **`webhook`** β€” Real-time errors during execution - **`conclusionWebhook`** β€” End-of-run summary -### 🎨 Customize Webhook Appearance - -You can personalize how your webhook appears in Discord: - -- **`username`** β€” The display name shown in Discord (default: "Microsoft Rewards") -- **`avatarUrl`** β€” Direct URL to an image for the webhook's avatar - -**Example:** -```jsonc -"conclusionWebhook": { - "enabled": true, - "url": "YOUR_WEBHOOK_URL", - "username": "My Custom Bot Name", - "avatarUrl": "https://i.imgur.com/YourImage.png" -} -``` - -> **πŸ’‘ Tip:** If you set custom values for both `webhook` and `conclusionWebhook`, the conclusion webhook will use its own settings. Otherwise, it falls back to the main webhook settings. - ---- - -## 🎨 New Enhanced Format Features - -The **v2.4+** webhook format includes: - -### Visual Improvements -- ✨ **Clean, professional layout** with clear sections -- 🎨 **Color-coded status** (Green/Orange/Red based on results) -- πŸ“Š **Thumbnail image** for brand recognition -- ⏰ **Footer with timestamp** and version info -- πŸ”’ **Formatted numbers** with thousands separators - -### Better Organization -- **Main embed** β€” Global statistics and summary -- **Detail fields** β€” Per-account breakdown (auto-splits for many accounts) -- **Smart truncation** β€” Long emails and errors are shortened intelligently -- **Status icons** β€” βœ… Success, ⚠️ Warning, 🚫 Banned - -### More Information -- **Before/After points** β€” Clear progression tracking -- **Desktop vs Mobile** breakdown per account -- **Success rate** β€” Quick glance at account health -- **Performance metrics** β€” Average time and points per account - -### Smart Splitting -If you have many accounts (5+), the webhook automatically splits details into multiple fields to avoid hitting Discord's character limits. - --- ## πŸ› οΈ Troubleshooting diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..5ce7187 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,148 @@ +# πŸš€ Getting Started + +**From zero to your first run in 10 minutes** + +--- + +## βœ… Requirements + +- **Node.js 20+** β†’ [Download here](https://nodejs.org/) +- **Microsoft accounts** with email + password +- *Optional:* Docker for containers + +--- + +## ⚑ Quick Setup (Recommended) + +### Windows +```powershell +setup\setup.bat +``` + +### Linux / macOS +```bash +bash setup/setup.sh +``` + +### What Does It Do? + +1. βœ… Asks for your Microsoft credentials +2. βœ… Creates `accounts.json` automatically +3. βœ… Installs dependencies +4. βœ… Builds the project +5. βœ… Runs your first automation (optional) + +**That's it! πŸŽ‰** + +--- + +## 🎯 After Installation + +### 1️⃣ Enable Scheduler (Recommended) + +Run automatically once per day: + +**Edit** `src/config.jsonc`: +```jsonc +{ + "schedule": { + "enabled": true, + "time": "09:00", + "timeZone": "America/New_York" + } +} +``` + +**Start scheduler:** +```bash +npm run start:schedule +``` + +β†’ **[Full Scheduler Guide](./schedule.md)** + +--- + +### 2️⃣ Add Notifications (Optional) + +Get a summary after each run: + +```jsonc +{ + "conclusionWebhook": { + "enabled": true, + "url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL" + } +} +``` + +β†’ **[Discord Setup](./conclusionwebhook.md)** | **[NTFY Setup](./ntfy.md)** + +--- + +### 3️⃣ Enable Humanization (Anti-Ban) + +More natural behavior: + +```jsonc +{ + "humanization": { + "enabled": true + } +} +``` + +β†’ **[Humanization Guide](./humanization.md)** + +--- + +## πŸ› οΈ Common Issues + +| Problem | Solution | +|---------|----------| +| **"Node.js not found"** | Install Node.js 20+ and restart terminal | +| **"accounts.json missing"** | Run `setup/setup.bat` or create manually | +| **"Login failed"** | Check email/password in `accounts.json` | +| **"2FA prompt"** | Add TOTP secret β†’ [2FA Guide](./accounts.md) | +| **Script crashes** | Check [Diagnostics Guide](./diagnostics.md) | + +--- + +## πŸ”§ Manual Setup (Advanced) + +
+Click to expand + +```bash +# 1. Configure accounts +cp src/accounts.example.json src/accounts.json +# Edit accounts.json with your credentials + +# 2. Install & build +npm install +npm run build + +# 3. Run +npm start +``` + +
+ +--- + +## πŸ“š Next Steps + +**Everything works?** +β†’ **[Setup Scheduler](./schedule.md)** for daily automation + +**Need 2FA?** +β†’ **[Accounts & TOTP Guide](./accounts.md)** + +**Want Docker?** +β†’ **[Docker Guide](./docker.md)** + +**Having issues?** +β†’ **[Diagnostics](./diagnostics.md)** + +--- + +**[← Back to Hub](./index.md)** | **[All Docs](./index.md)** diff --git a/package-lock.json b/package-lock.json index 1656727..8dff8c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.3.0", "license": "ISC", "dependencies": { "axios": "^1.8.4", diff --git a/package.json b/package.json index 1244999..8947da1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.3.0", "description": "Automatically do tasks for Microsoft Rewards but in TS!", "private": true, "main": "index.js", @@ -27,7 +27,7 @@ "start:schedule": "node --enable-source-maps ./dist/scheduler.js", "lint": "eslint \"src/**/*.{ts,tsx}\"", "prepare": "npm run build", - "setup": "node ./setup/update/setup.mjs", + "setup": "node ./setup/setup.mjs", "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", "create-docker": "docker build -t microsoft-rewards-script-docker ." }, diff --git a/setup/setup.bat b/setup/setup.bat index 1ea6de2..b1171fc 100644 --- a/setup/setup.bat +++ b/setup/setup.bat @@ -1,22 +1,19 @@ @echo off setlocal -REM Wrapper to run setup via npm (Windows) -REM Navigates to project root and runs npm run setup +REM Lightweight wrapper to run setup.mjs without prereq detection (Windows) +REM Assumes Node is already installed and available in PATH. set SCRIPT_DIR=%~dp0 -set PROJECT_ROOT=%SCRIPT_DIR%.. +set SETUP_FILE=%SCRIPT_DIR%setup.mjs -if not exist "%PROJECT_ROOT%\package.json" ( - echo [ERROR] package.json not found in project root. +if not exist "%SETUP_FILE%" ( + echo [ERROR] setup.mjs not found next to this batch file. pause exit /b 1 ) -echo Navigating to project root... -cd /d "%PROJECT_ROOT%" - -echo Running setup script via npm... -call npm run setup +echo Running setup script... +node "%SETUP_FILE%" set EXITCODE=%ERRORLEVEL% echo. echo Setup finished with exit code %EXITCODE%. diff --git a/setup/update/setup.mjs b/setup/setup.mjs similarity index 95% rename from setup/update/setup.mjs rename to setup/setup.mjs index bee1eda..21165d6 100644 --- a/setup/update/setup.mjs +++ b/setup/setup.mjs @@ -3,7 +3,7 @@ * Unified cross-platform setup script for Microsoft Rewards Script V2. * * Features: - * - Renames accounts.example.jsonc -> accounts.json (idempotent) + * - Renames accounts.example.json -> accounts.json (idempotent) * - Guides user through account configuration (email, password, TOTP, proxy) * - Explains config.jsonc structure and key settings * - Installs dependencies (npm install) @@ -25,8 +25,8 @@ import { spawn } from 'child_process'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Project root = two levels up from setup/update directory -const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); +// Project root = parent of this setup directory +const PROJECT_ROOT = path.resolve(__dirname, '..'); const SRC_DIR = path.join(PROJECT_ROOT, 'src'); function log(msg) { console.log(msg); } @@ -35,16 +35,16 @@ function error(msg) { console.error(msg); } function renameAccountsIfNeeded() { const accounts = path.join(SRC_DIR, 'accounts.json'); - const example = path.join(SRC_DIR, 'accounts.example.jsonc'); + const example = path.join(SRC_DIR, 'accounts.example.json'); if (fs.existsSync(accounts)) { log('accounts.json already exists - skipping rename.'); return; } if (fs.existsSync(example)) { - log('Renaming accounts.example.jsonc to accounts.json...'); + log('Renaming accounts.example.json to accounts.json...'); fs.renameSync(example, accounts); } else { - warn('Neither accounts.json nor accounts.example.jsonc found.'); + warn('Neither accounts.json nor accounts.example.json found.'); } } diff --git a/setup/setup.sh b/setup/setup.sh index 759d8cc..b32c43d 100644 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -1,19 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# Wrapper to run setup via npm (Linux/macOS) +# Wrapper to run unified Node setup script (setup/setup.mjs) regardless of CWD. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SETUP_FILE="${SCRIPT_DIR}/setup.mjs" echo "=== Prerequisite Check ===" -if command -v npm >/dev/null 2>&1; then - NPM_VERSION="$(npm -v 2>/dev/null || true)" - echo "npm detected: ${NPM_VERSION}" +if command -v node >/dev/null 2>&1; then + NODE_VERSION="$(node -v 2>/dev/null || true)" + echo "Node detected: ${NODE_VERSION}" else - echo "[ERROR] npm not detected." - echo " Install Node.js and npm from nodejs.org or your package manager" - exit 1 + echo "[WARN] Node.js not detected." + echo " Install (Linux): use your package manager (e.g. 'sudo apt install nodejs npm' or install from nodejs.org for latest)." fi if command -v git >/dev/null 2>&1; then @@ -24,12 +23,19 @@ else echo " Install (Linux): e.g. 'sudo apt install git' (or your distro equivalent)." fi -if [ ! -f "${PROJECT_ROOT}/package.json" ]; then - echo "[ERROR] package.json not found at ${PROJECT_ROOT}" >&2 +if [ -z "${NODE_VERSION:-}" ]; then + read -r -p "Continue anyway? (yes/no) : " CONTINUE + case "${CONTINUE,,}" in + yes|y) ;; + *) echo "Aborting. Install prerequisites then re-run."; exit 1;; + esac +fi + +if [ ! -f "${SETUP_FILE}" ]; then + echo "[ERROR] setup.mjs not found at ${SETUP_FILE}" >&2 exit 1 fi echo -echo "=== Running setup script via npm ===" -cd "${PROJECT_ROOT}" -exec npm run setup +echo "=== Running setup script ===" +exec node "${SETUP_FILE}" diff --git a/setup/update/update.mjs b/setup/update/update.mjs index 305ccc5..0a000bc 100644 --- a/setup/update/update.mjs +++ b/setup/update/update.mjs @@ -1,21 +1,19 @@ /* eslint-disable linebreak-style */ /** - * Smart Auto-Update Script - * - * Intelligently updates while preserving user settings: - * - ALWAYS updates code files (*.ts, *.js, etc.) - * - ONLY updates config.jsonc if remote has changes to it - * - ONLY updates accounts.json if remote has changes to it - * - KEEPS user passwords/emails/settings otherwise + * Post-run auto-update script + * - If invoked with --git, runs: git fetch --all --prune; git pull --ff-only; npm ci; npm run build + * - If invoked with --docker, runs: docker compose pull; docker compose up -d * * Usage: * node setup/update/update.mjs --git * node setup/update/update.mjs --docker + * + * Notes: + * - Commands are safe-by-default: use --ff-only for pull to avoid merge commits. + * - Script is no-op if the relevant tool is not available or commands fail. */ -import { spawn, execSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' +import { spawn } from 'node:child_process' function run(cmd, args, opts = {}) { return new Promise((resolve) => { @@ -27,166 +25,20 @@ function run(cmd, args, opts = {}) { async function which(cmd) { const probe = process.platform === 'win32' ? 'where' : 'which' - const code = await run(probe, [cmd], { stdio: 'ignore' }) + const code = await run(probe, [cmd]) return code === 0 } -function exec(cmd) { - try { - return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() - } catch { - return null - } -} - async function updateGit() { const hasGit = await which('git') - if (!hasGit) { - 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...') + if (!hasGit) return 1 await run('git', ['fetch', '--all', '--prune']) - - // 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 pullCode = await run('git', ['pull', '--ff-only']) + if (pullCode !== 0) return pullCode const hasNpm = await which('npm') if (!hasNpm) return 0 - - console.log('\nInstalling dependencies...') await run('npm', ['ci']) - - 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 + return run('npm', ['run', 'build']) } async function updateDocker() { @@ -209,17 +61,7 @@ async function main() { if (doDocker && code === 0) { code = await updateDocker() } - - // Only exit if not called from scheduler - // When FROM_SCHEDULER=1, the parent script will handle process lifecycle - if (process.env.FROM_SCHEDULER !== '1') { - process.exit(code) - } + process.exit(code) } -main().catch(() => { - // Only exit on error if not called from scheduler - if (process.env.FROM_SCHEDULER !== '1') { - process.exit(1) - } -}) +main().catch(() => process.exit(1)) diff --git a/src/accounts.example.json b/src/accounts.example.json new file mode 100644 index 0000000..8af141d --- /dev/null +++ b/src/accounts.example.json @@ -0,0 +1,31 @@ +{ + "_note": "Microsoft allows up to 3 new accounts per IP per day; in practice it is recommended not to exceed around 5 active accounts per household IP to avoid looking suspicious; there is no official lifetime cap, but creating too many accounts quickly may trigger verification (phone, OTP, captcha); Microsoft discourages bulk account creation from the same IP; unusual activity can result in temporary blocks or account restrictions.", + "accounts": [ + { + "email": "email_1", + "password": "password_1", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + "email": "email_2", + "password": "password_2", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + } + ] +} \ No newline at end of file diff --git a/src/accounts.example.jsonc b/src/accounts.example.jsonc deleted file mode 100644 index d3edde1..0000000 --- a/src/accounts.example.jsonc +++ /dev/null @@ -1,151 +0,0 @@ -{ - // ============================================================ - // πŸ“§ MICROSOFT ACCOUNTS CONFIGURATION - // ============================================================ - - // ⚠️ IMPORTANT SECURITY NOTICE - // This file contains sensitive credentials. Never commit the real accounts.jsonc to version control. - // The .gitignore is configured to exclude accounts.jsonc but you should verify it's not tracked. - - // πŸ“Š MICROSOFT ACCOUNT LIMITS (Unofficial Guidelines) - // - New accounts per IP per day: ~3 (official soft limit) - // - Recommended active accounts per household IP: ~5 (to avoid suspicion) - // - Creating too many accounts quickly may trigger verification (phone, OTP, captcha) - // - Unusual activity can result in temporary blocks or account restrictions - - "accounts": [ - { - // ============================================================ - // πŸ‘€ ACCOUNT 1 - // ============================================================ - - // Enable or disable this account (true = active, false = skip) - "enabled": true, - - // Microsoft account email address - "email": "email_1@outlook.com", - - // Account password - "password": "password_1", - - // Two-Factor Authentication (2FA) TOTP secret (optional but HIGHLY recommended for security) - // Get this from your authenticator app (e.g., Microsoft Authenticator, Google Authenticator) - // Format: base32 secret key (e.g., "JBSWY3DPEHPK3PXP") - // Leave empty "" if 2FA is not enabled - "totp": "", - - // ⚠️ REQUIRED: Recovery email address associated with this Microsoft account - // During login, Microsoft shows the first 2 characters and the domain of the recovery email (e.g., "ab***@example.com") - // This field is MANDATORY to detect account compromise or bans: - // - The script compares what Microsoft displays with this configured recovery email - // - If they don't match, it alerts you that the account may be compromised or the recovery email was changed - // - This security check helps identify hijacked accounts before they cause issues - // Format: Full recovery email address (e.g., "backup@gmail.com") - "recoveryEmail": "your_email@domain.com", - - // ============================================================ - // 🌐 PROXY CONFIGURATION (Optional) - // ============================================================ - - "proxy": { - // Enable proxy for HTTP requests (axios/API calls) - // If false, proxy is only used for browser automation - "proxyAxios": true, - - // Proxy server URL (without protocol) - // Examples: "proxy.example.com", "123.45.67.89" - // Leave empty "" to disable proxy for this account - "url": "", - - // Proxy port number - "port": 0, - - // Proxy authentication username (leave empty if no auth required) - "username": "", - - // Proxy authentication password (leave empty if no auth required) - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 2 - // ============================================================ - - "enabled": false, - "email": "email_2@outlook.com", - "password": "password_2", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 3 - // ============================================================ - - "enabled": false, - "email": "email_3@outlook.com", - "password": "password_3", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 4 - // ============================================================ - - "enabled": false, - "email": "email_4@outlook.com", - "password": "password_4", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 5 - // ============================================================ - - "enabled": false, - "email": "email_5@outlook.com", - "password": "password_5", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - } - ] -} diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index c183365..f83d21c 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -54,8 +54,7 @@ class Browser { const engineName = 'chromium' // current hard-coded engine this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log browser = await playwright.chromium.launch({ - // Optional: uncomment to use Edge instead of Chromium - // channel: 'msedge', + //channel: 'msedge', // Uses Edge instead of chrome headless, ...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }), args: [ @@ -71,7 +70,7 @@ class Browser { const msg = (e instanceof Error ? e.message : String(e)) // Common missing browser executable guidance if (/Executable doesn't exist/i.test(msg)) { - this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies (or set AUTO_INSTALL_BROWSERS=1 to auto-attempt).', 'error') + this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run: "npx playwright install chromium" (or set AUTO_INSTALL_BROWSERS=1 to auto attempt).', 'error') } else { this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error') } diff --git a/src/config.jsonc b/src/config.jsonc index e9a9711..dcfc373 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -45,9 +45,8 @@ "runOnZeroPoints": false, // Number of account clusters (processes) to run concurrently "clusters": 1, - // How many times to run through all accounts in sequence (1 = process each account once, 2 = twice, etc.) - // Higher values can catch missed tasks but increase detection risk - "passesPerRun": 3 + // Number of passes per invocation (usually 1) + "passesPerRun": 1 }, "schedule": { @@ -208,18 +207,12 @@ // Live logs webhook (Discord or similar). URL = your webhook endpoint "webhook": { "enabled": false, - "url": "", - // Optional: Customize webhook appearance - "username": "Live Logs", - "avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png" + "url": "" }, // Rich end-of-run summary webhook (Discord or similar) "conclusionWebhook": { "enabled": false, - "url": "", - // Optional: Customize webhook appearance (overrides webhook settings for conclusion messages) - "username": "Microsoft Rewards", - "avatarUrl": "https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png" + "url": "" }, // NTFY push notifications (plain text) "ntfy": { @@ -300,44 +293,6 @@ "git": true, "docker": false, // Custom updater script path (relative to repo root) - "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 + "scriptPath": "setup/update/update.mjs" } } - diff --git a/src/constants.ts b/src/constants.ts index a45347a..3a80f8f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -63,12 +63,5 @@ export const DISCORD = { COLOR_CRIMSON: 0xDC143C, COLOR_ORANGE: 0xFFA500, COLOR_BLUE: 0x3498DB, - COLOR_GREEN: 0x00D26A, - AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' -} as const - -export const META = { - - C: 'aHR0cHM6Ly9kaXNjb3JkLmdnL2tuMzY5NUt4MzI=', - R: 'aHR0cHM6Ly9naXRodWIuY29tL0xpZ2h0NjAtMS9NaWNyb3NvZnQtUmV3YXJkcy1SZXdp' + COLOR_GREEN: 0x00D26A } as const diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 7aba9a9..c576cad 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -28,14 +28,7 @@ const SELECTORS = { const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' } const DEFAULT_TIMEOUTS = { - 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 - })(), + loginMaxMs: Number(process.env.LOGIN_MAX_WAIT_MS || 180000), // 3 min short: 500, medium: 1500, long: 3000 @@ -78,12 +71,6 @@ export class Login { // --------------- Public API --------------- async login(page: Page, email: string, password: string, totpSecret?: string) { try { - // Clear any existing intervals from previous runs - if (this.compromisedInterval) { - clearInterval(this.compromisedInterval) - this.compromisedInterval = undefined - } - this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process') this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined @@ -298,45 +285,40 @@ export class Login { let userInput: string | null = null let checkInterval: NodeJS.Timeout | null = null - try { - const inputPromise = new Promise(res => { - rl.question('Enter 2FA code:\n', ans => { + const inputPromise = new Promise(res => { + rl.question('Enter 2FA code:\n', ans => { + if (checkInterval) clearInterval(checkInterval) + rl.close() + res(ans.trim()) + }) + }) + + // Check every 2 seconds if user manually progressed past the dialog + checkInterval = setInterval(async () => { + try { + await this.bot.browser.utils.tryDismissAllMessages(page) + // Check if we're no longer on 2FA page + const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false) + if (!still2FA) { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn') if (checkInterval) clearInterval(checkInterval) rl.close() - res(ans.trim()) - }) - }) + userInput = 'skip' // Signal to skip submission + } + } catch {/* ignore */} + }, 2000) - // Check every 2 seconds if user manually progressed past the dialog - checkInterval = setInterval(async () => { - try { - await this.bot.browser.utils.tryDismissAllMessages(page) - // Check if we're no longer on 2FA page - const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false) - if (!still2FA) { - this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn') - if (checkInterval) clearInterval(checkInterval) - rl.close() - userInput = 'skip' // Signal to skip submission - } - } catch {/* ignore */} - }, 2000) - - const code = await inputPromise - - if (code === 'skip' || userInput === 'skip') { - this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)') - return - } - - await page.fill('input[name="otc"]', code) - await page.keyboard.press('Enter') - this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted') - } finally { - // Ensure cleanup happens even if errors occur - if (checkInterval) clearInterval(checkInterval) - try { rl.close() } catch {/* ignore */} + const code = await inputPromise + if (checkInterval) clearInterval(checkInterval) + + if (code === 'skip' || userInput === 'skip') { + this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)') + return } + + await page.fill('input[name="otc"]', code) + await page.keyboard.press('Enter') + this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted') } private async ensureTotpInput(page: Page): Promise { @@ -776,19 +758,12 @@ export class Login { this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level) try { const { ConclusionWebhook } = await import('../util/ConclusionWebhook') - const fields = [ - { name: 'Account', value: incident.account }, - ...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []), - ...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []), - ...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : []) - ] - await ConclusionWebhook( - this.bot.config, - `πŸ” ${incident.kind}`, - '_Security check by @Light_', - fields, - severity === 'critical' ? 0xFF0000 : 0xFFAA00 - ) + await ConclusionWebhook(this.bot.config,'', { embeds:[{ title:`πŸ” ${incident.kind}`, description:'Security check by @Light', color: severity==='critical'?0xFF0000:0xFFAA00, fields:[ + { name:'Account', value: incident.account }, + ...(incident.details?.length?[{ name:'Details', value: incident.details.join('\n') }]:[]), + ...(incident.next?.length?[{ name:'Next steps', value: incident.next.join('\n') }]:[]), + ...(incident.docsUrl?[{ name:'Docs', value: incident.docsUrl }]:[]) + ] }] }) } catch {/* ignore */} } diff --git a/src/index.ts b/src/index.ts index 5075e82..efdbad7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,9 +43,6 @@ export class MicrosoftRewardsBot { public compromisedModeActive: boolean = false public compromisedReason?: string public compromisedEmail?: string - // Mutex-like flag to prevent parallel execution when config.parallel is accidentally misconfigured - private isDesktopRunning: boolean = false - private isMobileRunning: boolean = false private pointsCanCollect: number = 0 private pointsInitial: number = 0 @@ -188,13 +185,24 @@ export class MicrosoftRewardsBot { const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => { try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - 'πŸ’³ Spend Detected', - `**Account:** ${account.email}\n**Spent:** -${delta} points\n**Current:** ${nowPts} points\n**Session spent:** ${cumulativeSpent} points`, - undefined, - 0xFFAA00 - ) + const title = 'πŸ’³ Spend detected (Buy Mode)' + const desc = [ + `Account: ${account.email}`, + `Spent: -${delta} points`, + `Current: ${nowPts} points`, + `Session spent: ${cumulativeSpent} points` + ].join('\n') + await ConclusionWebhook(this.config, '', { + context: 'spend', + embeds: [ + { + title, + description: desc, + // Use warn color so NTFY is sent as warn + color: 0xFFAA00 + } + ] + }) } catch (e) { this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn') } @@ -253,11 +261,7 @@ export class MicrosoftRewardsBot { } // Save cookies and close monitor; keep main page open for user until they close it themselves - 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 { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch { /* ignore */ } try { if (!monitor.isClosed()) await monitor.close() } catch {/* ignore */} // Send a final minimal conclusion webhook for this manual session @@ -314,23 +318,19 @@ export class MicrosoftRewardsBot { β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• ` - // Read package version and build banner info - const pkgPath = path.join(__dirname, '../', 'package.json') - let version = 'unknown' try { + const pkgPath = path.join(__dirname, '../', 'package.json') + let version = 'unknown' if (fs.existsSync(pkgPath)) { const raw = fs.readFileSync(pkgPath, 'utf-8') const pkg = JSON.parse(raw) version = pkg.version || version } - } catch { - // Ignore version read errors - } - - // Display appropriate banner based on mode - const displayBanner = this.buyMode.enabled ? buyModeBanner : banner - console.log(displayBanner) - console.log('='.repeat(80)) + + // Show appropriate banner based on mode + const displayBanner = this.buyMode.enabled ? buyModeBanner : banner + console.log(displayBanner) + console.log('='.repeat(80)) if (this.buyMode.enabled) { console.log(` Version: ${version} | Process: ${process.pid} | Buy Mode: Active`) @@ -376,9 +376,19 @@ export class MicrosoftRewardsBot { } } console.log('='.repeat(80) + '\n') - } - - // Return summaries (used when clusters==1) + } catch { + const displayBanner = this.buyMode.enabled ? buyModeBanner : banner + console.log(displayBanner) + console.log('='.repeat(50)) + if (this.buyMode.enabled) { + console.log(' Microsoft Rewards Buy Mode Started') + console.log(' See buy-mode.md for details') + } else { + console.log(' Microsoft Rewards Script Started') + } + console.log('='.repeat(50) + '\n') + } + } // Return summaries (used when clusters==1) public getSummaries() { return this.accountSummaries } @@ -387,15 +397,8 @@ export class MicrosoftRewardsBot { log('main', 'MAIN-PRIMARY', 'Primary process started') const totalAccounts = this.accounts.length - - // Validate accounts exist - if (totalAccounts === 0) { - log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn') - process.exit(0) - } - // If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers. - const workerCount = Math.min(this.config.clusters, totalAccounts) + const workerCount = Math.min(this.config.clusters, totalAccounts || 1) const accountChunks = this.utils.chunkArray(this.accounts, workerCount) // Reset activeWorkers to actual spawn count (constructor used raw clusters) this.activeWorkers = workerCount @@ -403,13 +406,7 @@ export class MicrosoftRewardsBot { for (let i = 0; i < workerCount; i++) { const worker = cluster.fork() const chunk = accountChunks[i] || [] - - // 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 as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk }) worker.on('message', (msg: unknown) => { const m = msg as { type?: string; data?: AccountSummary[] } if (m && m.type === 'summary' && Array.isArray(m.data)) { @@ -451,13 +448,8 @@ export class MicrosoftRewardsBot { try { await this.runAutoUpdate() } catch {/* ignore */} - // Only exit if not spawned by scheduler - if (!process.env.SCHEDULER_HEARTBEAT_FILE) { - log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') - process.exit(0) - } else { - log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.') - } + log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') + process.exit(0) })() } }) @@ -544,72 +536,52 @@ export class MicrosoftRewardsBot { } errors.push(formatFullErr('mobile', e)); return null }) - const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise]) - - // Handle desktop result - if (desktopResult.status === 'fulfilled' && desktopResult.value) { - desktopInitial = desktopResult.value.initialPoints - desktopCollected = desktopResult.value.collectedPoints - } else if (desktopResult.status === 'rejected') { - log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error') - errors.push(formatFullErr('desktop-rejected', desktopResult.reason)) + const [desktopResult, mobileResult] = await Promise.all([desktopPromise, mobilePromise]) + if (desktopResult) { + desktopInitial = desktopResult.initialPoints + desktopCollected = desktopResult.collectedPoints } - - // Handle mobile result - if (mobileResult.status === 'fulfilled' && mobileResult.value) { - mobileInitial = mobileResult.value.initialPoints - mobileCollected = mobileResult.value.collectedPoints - } else if (mobileResult.status === 'rejected') { - log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error') - errors.push(formatFullErr('mobile-rejected', mobileResult.reason)) + if (mobileResult) { + mobileInitial = mobileResult.initialPoints + mobileCollected = mobileResult.collectedPoints } } else { - // Sequential execution with safety checks - if (this.isDesktopRunning || this.isMobileRunning) { - log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error') - errors.push('race-condition-detected') - } else { - this.isMobile = false - this.isDesktopRunning = true - const desktopResult = await this.Desktop(account).catch(e => { + this.isMobile = false + const desktopResult = await this.Desktop(account).catch(e => { + const msg = e instanceof Error ? e.message : String(e) + log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') + const bd = detectBanReason(e) + if (bd.status) { + banned.status = true; banned.reason = bd.reason.substring(0,200) + void this.handleImmediateBanAlert(account.email, banned.reason) + } + errors.push(formatFullErr('desktop', e)); return null + }) + if (desktopResult) { + desktopInitial = desktopResult.initialPoints + desktopCollected = desktopResult.collectedPoints + } + + // If banned or compromised detected, skip mobile to save time + if (!banned.status && !this.compromisedModeActive) { + this.isMobile = true + const mobileResult = await this.Mobile(account).catch(e => { const msg = e instanceof Error ? e.message : String(e) - log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') + 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('desktop', e)); return null + errors.push(formatFullErr('mobile', e)); return null }) - if (desktopResult) { - desktopInitial = desktopResult.initialPoints - desktopCollected = desktopResult.collectedPoints - } - this.isDesktopRunning = false - - // If banned or compromised detected, skip mobile to save time - if (!banned.status && !this.compromisedModeActive) { - this.isMobile = true - this.isMobileRunning = true - const mobileResult = await this.Mobile(account).catch(e => { - const msg = e instanceof Error ? e.message : String(e) - log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') - const bd = detectBanReason(e) - if (bd.status) { - banned.status = true; banned.reason = bd.reason.substring(0,200) - void this.handleImmediateBanAlert(account.email, banned.reason) - } - errors.push(formatFullErr('mobile', e)); return null - }) - if (mobileResult) { - mobileInitial = mobileResult.initialPoints - mobileCollected = mobileResult.collectedPoints - } - this.isMobileRunning = false - } else { - const why = banned.status ? 'banned status' : 'compromised status' - log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn') + if (mobileResult) { + mobileInitial = mobileResult.initialPoints + mobileCollected = mobileResult.collectedPoints } + } else { + const why = banned.status ? 'banned status' : 'compromised status' + log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn') } } @@ -661,14 +633,10 @@ export class MicrosoftRewardsBot { // If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open if (this.compromisedModeActive || this.globalStandby.active) { log('main','SECURITY','Compromised or banned detected. Global standby engaged: we will NOT proceed to other accounts until resolved. Keeping process alive. Press CTRL+C to exit when done. Security check by @Light','warn','yellow') - // Periodic heartbeat with cleanup on exit - const standbyInterval = setInterval(() => { + // Periodic heartbeat + setInterval(() => { log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow') }, 5 * 60 * 1000) - - // Cleanup on process exit - process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) }) - process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) }) return } // If in worker mode (clusters>1) send summaries to primary @@ -682,8 +650,10 @@ export class MicrosoftRewardsBot { // Cleanup heartbeat timer/file at end of run if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } } if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } } - // After conclusion, run optional auto-update - await this.runAutoUpdate().catch(() => {/* ignore update errors */}) + // After conclusion, run optional auto-update (only if not in scheduler mode) + if (!process.env.SCHEDULER_HEARTBEAT_FILE) { + await this.runAutoUpdate().catch(() => {/* ignore update errors */}) + } } // Only exit if not spawned by scheduler if (!process.env.SCHEDULER_HEARTBEAT_FILE) { @@ -697,13 +667,17 @@ export class MicrosoftRewardsBot { const h = this.config?.humanization if (!h || h.immediateBanAlert === false) return const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - '🚫 Ban Detected', - `**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`, - undefined, - DISCORD.COLOR_RED - ) + const title = '🚫 Ban detected' + const desc = [`Account: ${email}`, `Reason: ${reason || 'detected by heuristics'}`].join('\n') + await ConclusionWebhook(this.config, `${title}\n${desc}`, { + embeds: [ + { + title, + description: desc, + color: DISCORD.COLOR_RED + } + ] + }) } catch (e) { log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn') } @@ -762,20 +736,19 @@ export class MicrosoftRewardsBot { log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - 'πŸ” Security Alert (Post-Login)', - `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks\n\n_Security check by @Light_`, - undefined, - 0xFFAA00 - ) + await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Logged in successfully; leaving browser open. Security check by @Light`, { + context: 'compromised', + embeds: [ + { + title: 'πŸ” Security alert (post-login)', + description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving browser open; skipping tasks`, + color: 0xFFAA00 + } + ] + }) } catch {/* ignore */} // Save session for convenience, but do not close the browser - try { - await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) - } catch (e) { - log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn') - } + try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ } return { initialPoints: 0, collectedPoints: 0 } } @@ -866,19 +839,18 @@ export class MicrosoftRewardsBot { log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - 'πŸ” Security Alert (Mobile)', - `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks\n\n_Security check by @Light_`, - undefined, - 0xFFAA00 - ) + await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Mobile flow halted; leaving browser open. Security check by @Light`, { + context: 'compromised', + embeds: [ + { + title: 'πŸ” Security alert (mobile)', + description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving mobile browser open; skipping tasks`, + color: 0xFFAA00 + } + ] + }) } 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') - } + try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ } return { initialPoints: 0, collectedPoints: 0 } } this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email) @@ -972,7 +944,7 @@ export class MicrosoftRewardsBot { } private async sendConclusion(summaries: AccountSummary[]) { - const { ConclusionWebhookEnhanced } = await import('./util/ConclusionWebhook') + const { ConclusionWebhook } = await import('./util/ConclusionWebhook') const cfg = this.config const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled) @@ -987,7 +959,6 @@ export class MicrosoftRewardsBot { let totalEnd = 0 let totalDuration = 0 let accountsWithErrors = 0 - let accountsBanned = 0 let successes = 0 // Calculate summary statistics @@ -996,9 +967,8 @@ export class MicrosoftRewardsBot { totalInitial += s.initialTotal totalEnd += s.endTotal totalDuration += s.durationMs - if (s.banned?.status) accountsBanned++ if (s.errors.length) accountsWithErrors++ - if (!s.banned?.status && !s.errors.length) successes++ + else successes++ } const avgDuration = totalDuration / totalAccounts @@ -1015,23 +985,67 @@ export class MicrosoftRewardsBot { } } catch { /* ignore */ } - // Send enhanced webhook - if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) { - await ConclusionWebhookEnhanced(cfg, { - version, - runId: this.runId, - totalAccounts, - successes, - accountsWithErrors, - accountsBanned, - totalCollected, - totalInitial, - totalEnd, - avgPointsPerAccount, - totalDuration, - avgDuration, - summaries + // Build clean embed with account details + type DiscordField = { name: string; value: string; inline?: boolean } + type DiscordEmbed = { + title?: string + description?: string + color?: number + fields?: DiscordField[] + thumbnail?: { url: string } + timestamp?: string + footer?: { text: string; icon_url?: string } + } + + const accountDetails: string[] = [] + for (const s of summaries) { + const statusIcon = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : 'βœ…') + const line = `${statusIcon} **${s.email}** β†’ +${s.totalCollected}pts (πŸ–₯️${s.desktopCollected} πŸ“±${s.mobileCollected}) β€’ ${formatDuration(s.durationMs)}` + accountDetails.push(line) + if (s.banned?.status) accountDetails.push(` β”” Banned: ${s.banned.reason || 'detected'}`) + if (s.errors.length > 0) accountDetails.push(` β”” Errors: ${s.errors.slice(0, 2).join(', ')}`) + } + + const embed: DiscordEmbed = { + title: '🎯 Microsoft Rewards - Daily Summary', + description: [ + '**πŸ“Š Global Statistics**', + `β”œ Total Points: **${totalInitial}** β†’ **${totalEnd}** (+**${totalCollected}**)`, + `β”œ Accounts: βœ… ${successes} β€’ ${accountsWithErrors > 0 ? `⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`, + `β”œ Average: **${avgPointsPerAccount}pts/account** β€’ **${formatDuration(avgDuration)}/account**`, + `β”” Runtime: **${formatDuration(totalDuration)}**`, + '', + '**πŸ“ˆ Account Details**', + ...accountDetails + ].filter(Boolean).join('\n'), + color: accountsWithErrors > 0 ? DISCORD.COLOR_ORANGE : DISCORD.COLOR_GREEN, + thumbnail: { + url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' + }, + timestamp: new Date().toISOString(), + footer: { + text: `MS Rewards Bot v${version} β€’ Run ${this.runId}`, + icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' + } + } + + // NTFY plain text fallback + const fallback = [ + '🎯 Microsoft Rewards Summary', + `Accounts: ${totalAccounts} (βœ…${successes} ${accountsWithErrors > 0 ? `⚠️${accountsWithErrors}` : ''})`, + `Total: ${totalInitial}β†’${totalEnd} (+${totalCollected})`, + `Average: ${avgPointsPerAccount}pts/account β€’ ${formatDuration(avgDuration)}`, + `Runtime: ${formatDuration(totalDuration)}`, + '', + ...summaries.map(s => { + const st = s.banned?.status ? '🚫' : (s.errors.length ? '⚠️' : 'βœ…') + return `${st} ${s.email}: +${s.totalCollected}pts (πŸ–₯️${s.desktopCollected} πŸ“±${s.mobileCollected})` }) + ].join('\n') + + // Send webhook + if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) { + await ConclusionWebhook(cfg, fallback, { embeds: [embed] }) } // Write local JSON report @@ -1065,11 +1079,6 @@ export class MicrosoftRewardsBot { log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn') } - // Optional community notice (shown randomly in ~15% of successful runs) - if (Math.random() > 0.85 && successes > 0 && accountsWithErrors === 0) { - log('main','INFO','Want faster updates & enhanced anti-detection? Community builds available: https://discord.gg/kn3695Kx32') - } - } /** Reserve one diagnostics slot for this run (caps captures). */ @@ -1116,14 +1125,8 @@ export class MicrosoftRewardsBot { if (upd.docker) args.push('--docker') if (args.length === 0) return - // Pass scheduler flag to update script so it doesn't exit - const isSchedulerMode = !!process.env.SCHEDULER_HEARTBEAT_FILE - const env = isSchedulerMode - ? { ...process.env, FROM_SCHEDULER: '1' } - : process.env - await new Promise((resolve) => { - const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit', env }) + const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' }) child.on('close', () => resolve()) child.on('error', () => resolve()) }) @@ -1143,13 +1146,24 @@ export class MicrosoftRewardsBot { private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise { try { const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - '🚨 Global Security Standby Engaged', - `@everyone\n\n**Account:** ${email}\n**Reason:** ${reason}\n**Action:** Pausing all further accounts. We will not proceed until this is resolved.\n\n_Security check by @Light_`, - undefined, - DISCORD.COLOR_RED - ) + const title = '🚨 Global security standby engaged' + const desc = [ + `Account: ${email}`, + `Reason: ${reason}`, + 'Action: Pausing all further accounts. We will not proceed until this is resolved.', + 'Security check by @Light' + ].join('\n') + // Mention everyone in content for Discord visibility + const content = '@everyone ' + title + await ConclusionWebhook(this.config, content, { + embeds: [ + { + title, + description: desc, + color: DISCORD.COLOR_RED + } + ] + }) } catch (e) { log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn') } diff --git a/src/interface/Account.ts b/src/interface/Account.ts index 294c32d..52bbf41 100644 --- a/src/interface/Account.ts +++ b/src/interface/Account.ts @@ -1,6 +1,4 @@ export interface Account { - /** Enable/disable this account (if false, account will be skipped during execution) */ - enabled?: boolean; email: string; password: string; /** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */ diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 7115d80..6758aed 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -67,8 +67,8 @@ export interface ConfigSearchDelay { export interface ConfigWebhook { enabled: boolean; url: string; - username?: string; // Custom webhook username (default: "Microsoft Rewards") - avatarUrl?: string; // Custom webhook avatar URL + username?: string; // Optional override for displayed webhook name + avatarUrl?: string; // Optional avatar image URL } export interface ConfigNtfy { @@ -95,8 +95,6 @@ export interface ConfigUpdate { git?: boolean; // if true, run git pull + npm ci + npm run build after completion docker?: boolean; // if true, run docker update routine (compose pull/up) after completion scriptPath?: string; // optional custom path to update script relative to repo root - autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings) - autoUpdateAccounts?: boolean; // if true, allow auto-update of accounts.json when remote changes it (default: false to preserve credentials) } export interface ConfigBuyMode { diff --git a/src/scheduler.ts b/src/scheduler.ts index e9d7008..eea2ed6 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -13,12 +13,6 @@ type DateTimeInstance = ReturnType function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } { const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC' - - // Warn if an invalid timezone was provided - if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) { - void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn') - } - // Determine source string let src = '' if (typeof schedule?.useAmPm === 'boolean') { @@ -120,28 +114,13 @@ async function runOnePassWithWatchdog(): Promise { // Heartbeat-aware watchdog configuration // If a child is actively updating its heartbeat file, we allow it to run beyond the legacy timeout. // Defaults are generous to allow first-day passes to finish searches with delays. - const parseEnvNumber = (key: string, fallback: number, min: number, max: number): number => { - const val = Number(process.env[key] || fallback) - if (isNaN(val) || val < min || val > max) { - void log('main', 'SCHEDULER', `Invalid ${key}="${process.env[key]}". Using default ${fallback}`, 'warn') - return fallback - } - return val - } - - const staleHeartbeatMin = parseEnvNumber( - process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES ? 'SCHEDULER_STALE_HEARTBEAT_MINUTES' : 'SCHEDULER_PASS_TIMEOUT_MINUTES', - 30, 5, 1440 + const staleHeartbeatMin = Number( + process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES || process.env.SCHEDULER_PASS_TIMEOUT_MINUTES || 30 ) - const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120) - const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440) + const graceMin = Number(process.env.SCHEDULER_HEARTBEAT_GRACE_MINUTES || 15) + const hardcapMin = Number(process.env.SCHEDULER_PASS_HARDCAP_MINUTES || 480) // 8 hours const checkEveryMs = 60_000 // check once per minute - // Validate: stale should be >= grace - if (staleHeartbeatMin < graceMin) { - await log('main', 'SCHEDULER', `Warning: STALE_HEARTBEAT (${staleHeartbeatMin}m) < GRACE (${graceMin}m). Adjusting stale to ${graceMin}m`, 'warn') - } - // Fork per pass: safer because we can terminate a stuck child without killing the scheduler const forkPerPass = String(process.env.SCHEDULER_FORK_PER_PASS || 'true').toLowerCase() !== 'false' @@ -168,8 +147,6 @@ async function runOnePassWithWatchdog(): Promise { let finished = false const startedAt = Date.now() - let killTimeout: NodeJS.Timeout | undefined - const killChild = async (signal: NodeJS.Signals) => { try { await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn') @@ -185,8 +162,7 @@ async function runOnePassWithWatchdog(): Promise { if (runtimeMin >= hardcapMin) { log('main', 'SCHEDULER', `Pass exceeded hard cap of ${hardcapMin} minutes; terminating...`, 'warn') void killChild('SIGTERM') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) + setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) return } // Before grace, don't judge @@ -199,23 +175,19 @@ async function runOnePassWithWatchdog(): Promise { if (ageMin >= staleHeartbeatMin) { log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${staleHeartbeatMin}m). Terminating child...`, 'warn') void killChild('SIGTERM') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) + setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) } - } catch (err) { + } catch { // If file missing after grace, consider stale - const msg = err instanceof Error ? err.message : String(err) - log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn') + log('main', 'SCHEDULER', 'Heartbeat file missing after grace. Terminating child...', 'warn') void killChild('SIGTERM') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) + setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) } }, checkEveryMs) child.on('exit', async (code, signal) => { finished = true clearInterval(timer) - if (killTimeout) clearTimeout(killTimeout) // Cleanup heartbeat file try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ } if (signal) { @@ -231,7 +203,6 @@ async function runOnePassWithWatchdog(): Promise { child.on('error', async (err) => { finished = true clearInterval(timer) - if (killTimeout) clearTimeout(killTimeout) try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ } await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error') resolve() @@ -315,21 +286,9 @@ async function main() { let running = false // Optional initial jitter before the first run (to vary start time) - const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => { - const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0) - const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0) - if (isNaN(minVal) || minVal < 0) { - void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn') - return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal] - } - if (isNaN(maxVal) || maxVal < 0) { - void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn') - return [minVal, 0] - } - return [minVal, maxVal] - } - - const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX') + const initJitterMin = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MIN || process.env.SCHEDULER_INITIAL_JITTER_MIN || 0) + const initJitterMax = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MAX || process.env.SCHEDULER_INITIAL_JITTER_MAX || 0) + const initialJitterBounds: [number, number] = [isFinite(initJitterMin) ? initJitterMin : 0, isFinite(initJitterMax) ? initJitterMax : 0] const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0) if (runImmediate && !running) { @@ -368,9 +327,10 @@ async function main() { // Optional daily jitter to further randomize the exact start time each day let extraMs = 0 if (cronExpressions.length === 0) { - const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX') - const djMin = dailyJitterBounds[0] - const djMax = dailyJitterBounds[1] + const dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0) + const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0) + const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0 + const djMax = isFinite(dailyJitterMax) ? dailyJitterMax : 0 if (djMin > 0 || djMax > 0) { const mn = Math.max(0, Math.min(djMin, djMax)) const mx = Math.max(0, Math.max(djMin, djMax)) @@ -413,6 +373,6 @@ async function main() { } main().catch((e) => { - void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error') + console.error(e) process.exit(1) }) diff --git a/src/util/Analytics.ts b/src/util/Analytics.ts index 8cd7383..052e8c7 100644 --- a/src/util/Analytics.ts +++ b/src/util/Analytics.ts @@ -202,11 +202,6 @@ export class Analytics { ) } - // Optional community info footer (only in markdown exports) - lines.push('') - lines.push('---') - lines.push('*Looking for faster updates, advanced features, and better anti-detection? Join our community for enhanced versions and support.*') - return lines.join('\n') } diff --git a/src/util/Axios.ts b/src/util/Axios.ts index e05df61..e767188 100644 --- a/src/util/Axios.ts +++ b/src/util/Axios.ts @@ -24,14 +24,14 @@ class AxiosClient { const { url, port } = proxyConfig switch (true) { - case proxyConfig.url.startsWith('http://'): + case proxyConfig.url.startsWith('http'): return new HttpProxyAgent(`${url}:${port}`) - case proxyConfig.url.startsWith('https://'): + case proxyConfig.url.startsWith('https'): return new HttpsProxyAgent(`${url}:${port}`) - case proxyConfig.url.startsWith('socks://') || proxyConfig.url.startsWith('socks4://') || proxyConfig.url.startsWith('socks5://'): + case proxyConfig.url.startsWith('socks'): return new SocksProxyAgent(`${url}:${port}`) default: - throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`) + throw new Error(`Unsupported proxy protocol: ${url}`) } } @@ -42,54 +42,29 @@ class AxiosClient { return bypassInstance.request(config) } - let lastError: unknown - const maxAttempts = 2 - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await this.instance.request(config) - } catch (err: unknown) { - lastError = err - const axiosErr = err as AxiosError | undefined + try { + return await this.instance.request(config) + } catch (err: unknown) { + const axiosErr = err as AxiosError | undefined - // Detect HTTP proxy auth failures (status 407) and retry without proxy - if (axiosErr && axiosErr.response && axiosErr.response.status === 407) { - if (attempt < maxAttempts) { - await this.sleep(1000 * attempt) // Exponential backoff - } - const bypassInstance = axios.create() - return bypassInstance.request(config) - } - - // If proxied request fails with common proxy/network errors, retry with backoff - const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined - const code = e?.code || e?.cause?.code - const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND' - const msg = String(e?.message || '') - const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg) - - if (isNetErr || looksLikeProxyIssue) { - if (attempt < maxAttempts) { - // Exponential backoff: 1s, 2s, 4s, etc. - const delayMs = 1000 * Math.pow(2, attempt - 1) - await this.sleep(delayMs) - continue - } - // Last attempt: try without proxy - const bypassInstance = axios.create() - return bypassInstance.request(config) - } - - // Non-retryable error - throw err + // Detect HTTP proxy auth failures (status 407) and retry without proxy once. + if (!bypassProxy && axiosErr && axiosErr.response && axiosErr.response.status === 407) { + const bypassInstance = axios.create() + return bypassInstance.request(config) } + + // If proxied request fails with common proxy/network errors, retry once without proxy + const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined + const code = e?.code || e?.cause?.code + const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND' + const msg = String(e?.message || '') + const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg) + if (!bypassProxy && (isNetErr || looksLikeProxyIssue)) { + const bypassInstance = axios.create() + return bypassInstance.request(config) + } + throw err } - - throw lastError - } - - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) } } diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts index 12e8f7b..2265ef5 100644 --- a/src/util/ConclusionWebhook.ts +++ b/src/util/ConclusionWebhook.ts @@ -1,358 +1,106 @@ import axios from 'axios' import { Config } from '../interface/Config' import { Ntfy } from './Ntfy' -import { DISCORD } from '../constants' -import { log } from './Logger' -interface DiscordField { - name: string - value: string - inline?: boolean +// Avatar URL for webhook (new clean logo) +const AVATAR_URL = 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' + +type WebhookContext = 'summary' | 'ban' | 'security' | 'compromised' | 'spend' | 'error' | 'default' + +function pickUsername(ctx: WebhookContext, fallbackColor?: number): string { + switch (ctx) { + case 'summary': return 'MS Rewards - Daily Summary' + case 'ban': return 'MS Rewards - Ban Detected' + case 'security': return 'MS Rewards - Security Alert' + case 'compromised': return 'MS Rewards - Account Compromised' + case 'spend': return 'MS Rewards - Purchase Notification' + case 'error': return 'MS Rewards - Error Report' + default: return fallbackColor === 0xFF0000 ? 'MS Rewards - Error Report' : 'MS Rewards Bot' + } } +interface DiscordField { name: string; value: string; inline?: boolean } interface DiscordEmbed { title?: string description?: string color?: number fields?: DiscordField[] - timestamp?: string - footer?: { - text: string - icon_url?: string - } - thumbnail?: { - url: string - } - author?: { - name: string - icon_url?: string - } } -interface WebhookPayload { - username: string - avatar_url: string - embeds: DiscordEmbed[] -} - -interface AccountSummary { - email: string - totalCollected: number - desktopCollected: number - mobileCollected: number - initialTotal: number - endTotal: number - durationMs: number - errors: string[] - banned?: { status: boolean; reason?: string } -} - -interface ConclusionData { - version: string - runId: string - totalAccounts: number - successes: number - accountsWithErrors: number - accountsBanned: number - totalCollected: number - totalInitial: number - totalEnd: number - avgPointsPerAccount: number - totalDuration: number - avgDuration: number - summaries: AccountSummary[] +interface ConclusionPayload { + content?: string + embeds?: DiscordEmbed[] + context?: WebhookContext } /** - * Send a clean, structured Discord webhook notification + * Send a final structured summary to the configured webhook, + * and optionally mirror a plain-text summary to NTFY. + * + * This preserves existing webhook behavior while adding NTFY + * as a separate, optional channel. */ -export async function ConclusionWebhook( - config: Config, - title: string, - description: string, - fields?: DiscordField[], - color?: number -) { - const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url - const hasWebhook = config.webhook?.enabled && config.webhook.url +export async function ConclusionWebhook(config: Config, content: string, payload?: ConclusionPayload) { + // Send to both webhooks when available + const hasConclusion = !!(config.conclusionWebhook?.enabled && config.conclusionWebhook.url) + const hasWebhook = !!(config.webhook?.enabled && config.webhook.url) + const sameTarget = hasConclusion && hasWebhook && config.conclusionWebhook!.url === config.webhook!.url - if (!hasConclusion && !hasWebhook) return + const body: ConclusionPayload & { username?: string; avatar_url?: string } = {} + if (payload?.embeds) body.embeds = payload.embeds + if (content && content.trim()) body.content = content + const firstColor = payload?.embeds && payload.embeds[0]?.color + const ctx: WebhookContext = payload?.context || (firstColor === 0xFF0000 ? 'error' : 'default') + body.username = pickUsername(ctx, firstColor) + body.avatar_url = AVATAR_URL - const embed: DiscordEmbed = { - title, - description, - color: color || 0x0078D4, - timestamp: new Date().toISOString() - } - - if (fields && fields.length > 0) { - embed.fields = fields - } - - // Use custom webhook settings if provided, otherwise fall back to defaults - const webhookUsername = config.webhook?.username || config.conclusionWebhook?.username || 'Microsoft Rewards' - const webhookAvatarUrl = config.webhook?.avatarUrl || config.conclusionWebhook?.avatarUrl || DISCORD.AVATAR_URL - - const payload: WebhookPayload = { - username: webhookUsername, - avatar_url: webhookAvatarUrl, - embeds: [embed] - } - - const postWebhook = async (url: string, label: string) => { - const maxAttempts = 3 - let lastError: unknown = null - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Post to conclusion webhook if configured + const postWithRetry = async (url: string, label: string) => { + const max = 2 + let lastErr: unknown = null + for (let attempt = 1; attempt <= max; attempt++) { try { - await axios.post(url, payload, { - headers: { 'Content-Type': 'application/json' }, - timeout: 15000 - }) - log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`) + await axios.post(url, body, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 }) + console.log(`[Webhook:${label}] summary sent (attempt ${attempt}).`) return - } catch (error) { - lastError = error - if (attempt < maxAttempts) { - // Exponential backoff: 1s, 2s, 4s - const delayMs = 1000 * Math.pow(2, attempt - 1) - await new Promise(resolve => setTimeout(resolve, delayMs)) - } + } catch (e) { + lastErr = e + if (attempt === max) break + await new Promise(r => setTimeout(r, 1000 * attempt)) } } - log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error') + console.error(`[Webhook:${label}] failed after ${max} attempts:`, lastErr) } - const urls = new Set() - if (hasConclusion) urls.add(config.conclusionWebhook!.url) - if (hasWebhook) urls.add(config.webhook!.url) + if (hasConclusion) { + await postWithRetry(config.conclusionWebhook!.url, sameTarget ? 'conclusion+primary' : 'conclusion') + } + if (hasWebhook && !sameTarget) { + await postWithRetry(config.webhook!.url, 'primary') + } - await Promise.all( - Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`)) - ) - - // Optional NTFY notification + // NTFY: mirror a plain text summary (optional) if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) { - const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}` - const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log' - + let message = content || '' + if (!message && payload?.embeds && payload.embeds.length > 0) { + const e: DiscordEmbed = payload.embeds[0]! + const title = e.title ? `${e.title}\n` : '' + const desc = e.description ? `${e.description}\n` : '' + const totals = e.fields && e.fields[0]?.value ? `\n${e.fields[0].value}\n` : '' + message = `${title}${desc}${totals}`.trim() + } + if (!message) message = 'Microsoft Rewards run complete.' + // Choose NTFY level based on embed color (yellow = warn) + let embedColor: number | undefined + if (payload?.embeds && payload.embeds.length > 0) { + embedColor = payload.embeds[0]!.color + } + const ntfyType = embedColor === 0xFFAA00 ? 'warn' : 'log' try { await Ntfy(message, ntfyType) - log('main', 'NTFY', 'Notification sent successfully') - } catch (error) { - log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error') - } - } -} - -/** - * Enhanced conclusion webhook with beautiful formatting and clear statistics - */ -export async function ConclusionWebhookEnhanced(config: Config, data: ConclusionData) { - const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url - const hasWebhook = config.webhook?.enabled && config.webhook.url - - if (!hasConclusion && !hasWebhook) return - - // Helper to format duration - const formatDuration = (ms: number): string => { - const totalSeconds = Math.floor(ms / 1000) - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - - if (hours > 0) return `${hours}h ${minutes}m ${seconds}s` - if (minutes > 0) return `${minutes}m ${seconds}s` - return `${seconds}s` - } - - // Helper to create progress bar (future use) - // const createProgressBar = (current: number, max: number, length: number = 10): string => { - // const percentage = Math.min(100, Math.max(0, (current / max) * 100)) - // const filled = Math.round((percentage / 100) * length) - // const empty = length - filled - // return `${'β–ˆ'.repeat(filled)}${'β–‘'.repeat(empty)} ${percentage.toFixed(0)}%` - // } - - // Determine overall status and color - let statusEmoji = 'βœ…' - let statusText = 'Success' - let embedColor: number = DISCORD.COLOR_GREEN - - if (data.accountsBanned > 0) { - statusEmoji = '🚫' - statusText = 'Banned Accounts Detected' - embedColor = DISCORD.COLOR_RED - } else if (data.accountsWithErrors > 0) { - statusEmoji = '⚠️' - statusText = 'Completed with Warnings' - embedColor = DISCORD.COLOR_ORANGE - } - - // Build main summary description - const mainDescription = [ - `**Status:** ${statusEmoji} ${statusText}`, - `**Version:** v${data.version} β€’ **Run ID:** \`${data.runId}\``, - '', - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' - ].join('\n') - - // Build global statistics field - const globalStats = [ - `**πŸ’Ž Total Points Earned**`, - `\`${data.totalInitial.toLocaleString()}\` β†’ \`${data.totalEnd.toLocaleString()}\` **(+${data.totalCollected.toLocaleString()})**`, - '', - `**πŸ“Š Accounts Processed**`, - `βœ… Success: **${data.successes}** | ⚠️ Errors: **${data.accountsWithErrors}** | 🚫 Banned: **${data.accountsBanned}**`, - `Total: **${data.totalAccounts}** ${data.totalAccounts === 1 ? 'account' : 'accounts'}`, - '', - `**⚑ Performance**`, - `Average: **${data.avgPointsPerAccount}pts/account** in **${formatDuration(data.avgDuration)}**`, - `Total Runtime: **${formatDuration(data.totalDuration)}**` - ].join('\n') - - // Build per-account breakdown (split if too many accounts) - const accountFields: DiscordField[] = [] - const maxAccountsPerField = 5 - const accountChunks: AccountSummary[][] = [] - - for (let i = 0; i < data.summaries.length; i += maxAccountsPerField) { - accountChunks.push(data.summaries.slice(i, i + maxAccountsPerField)) - } - - accountChunks.forEach((chunk, chunkIndex) => { - const accountLines: string[] = [] - - chunk.forEach((acc) => { - const statusIcon = acc.banned?.status ? '🚫' : (acc.errors.length > 0 ? '⚠️' : 'βœ…') - const emailShort = acc.email.length > 25 ? acc.email.substring(0, 22) + '...' : acc.email - - accountLines.push(`${statusIcon} **${emailShort}**`) - accountLines.push(`β”” Points: **+${acc.totalCollected}** (πŸ–₯️ ${acc.desktopCollected} β€’ πŸ“± ${acc.mobileCollected})`) - accountLines.push(`β”” Duration: ${formatDuration(acc.durationMs)}`) - - if (acc.banned?.status) { - accountLines.push(`β”” 🚫 **Banned:** ${acc.banned.reason || 'Account suspended'}`) - } else if (acc.errors.length > 0) { - const errorPreview = acc.errors.slice(0, 1).join(', ') - accountLines.push(`β”” ⚠️ **Error:** ${errorPreview.length > 50 ? errorPreview.substring(0, 47) + '...' : errorPreview}`) - } - - accountLines.push('') // Empty line between accounts - }) - - const fieldName = accountChunks.length > 1 - ? `πŸ“ˆ Account Details (${chunkIndex + 1}/${accountChunks.length})` - : 'πŸ“ˆ Account Details' - - accountFields.push({ - name: fieldName, - value: accountLines.join('\n').trim(), - inline: false - }) - }) - - // Create embeds - const embeds: DiscordEmbed[] = [] - - // Main embed with summary - embeds.push({ - title: '🎯 Microsoft Rewards β€” Daily Summary', - description: mainDescription, - color: embedColor, - fields: [ - { - name: 'πŸ“Š Global Statistics', - value: globalStats, - inline: false - } - ], - thumbnail: { - url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' - }, - footer: { - text: `Microsoft Rewards Bot v${data.version} β€’ Completed at`, - icon_url: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' - }, - timestamp: new Date().toISOString() - }) - - // Add account details in separate embed(s) if needed - if (accountFields.length > 0) { - // If we have multiple fields, split into multiple embeds - accountFields.forEach((field, index) => { - if (index === 0 && embeds[0] && embeds[0].fields) { - // Add first field to main embed - embeds[0].fields.push(field) - } else { - // Create additional embeds for remaining fields - embeds.push({ - color: embedColor, - fields: [field], - timestamp: new Date().toISOString() - }) - } - }) - } - - // Use custom webhook settings - const webhookUsername = config.conclusionWebhook?.username || config.webhook?.username || 'Microsoft Rewards' - const webhookAvatarUrl = config.conclusionWebhook?.avatarUrl || config.webhook?.avatarUrl || DISCORD.AVATAR_URL - - const payload: WebhookPayload = { - username: webhookUsername, - avatar_url: webhookAvatarUrl, - embeds - } - - const postWebhook = async (url: string, label: string) => { - const maxAttempts = 3 - let lastError: unknown = null - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await axios.post(url, payload, { - headers: { 'Content-Type': 'application/json' }, - timeout: 15000 - }) - log('main', 'WEBHOOK', `${label} conclusion sent successfully (${data.totalAccounts} accounts, +${data.totalCollected}pts)`) - return - } catch (error) { - lastError = error - if (attempt < maxAttempts) { - const delayMs = 1000 * Math.pow(2, attempt - 1) - await new Promise(resolve => setTimeout(resolve, delayMs)) - } - } - } - log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error') - } - - const urls = new Set() - if (hasConclusion) urls.add(config.conclusionWebhook!.url) - if (hasWebhook) urls.add(config.webhook!.url) - - await Promise.all( - Array.from(urls).map((url, index) => postWebhook(url, `conclusion-webhook-${index + 1}`)) - ) - - // Optional NTFY notification (simplified summary) - if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) { - const message = [ - `🎯 Microsoft Rewards Summary`, - `Status: ${statusText}`, - `Points: ${data.totalInitial} β†’ ${data.totalEnd} (+${data.totalCollected})`, - `Accounts: ${data.successes}/${data.totalAccounts} successful`, - `Duration: ${formatDuration(data.totalDuration)}` - ].join('\n') - - const ntfyType = embedColor === DISCORD.COLOR_RED ? 'error' : embedColor === DISCORD.COLOR_ORANGE ? 'warn' : 'log' - - try { - await Ntfy(message, ntfyType) - log('main', 'NTFY', 'Conclusion notification sent successfully') - } catch (error) { - log('main', 'NTFY', `Failed to send conclusion notification: ${error instanceof Error ? error.message : String(error)}`, 'error') + console.log('Conclusion summary sent to NTFY.') + } catch (err) { + console.error('Failed to send conclusion summary to NTFY:', err) } } } diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts index 9febc47..0a21cd7 100644 --- a/src/util/ConfigValidator.ts +++ b/src/util/ConfigValidator.ts @@ -470,7 +470,6 @@ export class ConfigValidator { /** * Print validation results to console with color - * Note: This method intentionally uses console.log for CLI output formatting */ static printResults(result: ValidationResult): void { if (result.valid) { diff --git a/src/util/Load.ts b/src/util/Load.ts index 536d549..c255448 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -215,18 +215,12 @@ export function loadAccounts(): Account[] { raw = fs.readFileSync(full, 'utf-8') } else { // Try multiple locations to support both root mounts and dist mounts - // Support both .json and .jsonc extensions const candidates = [ path.join(__dirname, '../', file), // root/accounts.json (preferred) - path.join(__dirname, '../', file + 'c'), // root/accounts.jsonc path.join(__dirname, '../src', file), // fallback: file kept inside src/ - path.join(__dirname, '../src', file + 'c'), // src/accounts.jsonc path.join(process.cwd(), file), // cwd override - path.join(process.cwd(), file + 'c'), // cwd/accounts.jsonc path.join(process.cwd(), 'src', file), // cwd/src/accounts.json - path.join(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 + path.join(__dirname, file) // dist/accounts.json (legacy) ] let chosen: string | null = null for (const p of candidates) { @@ -248,10 +242,7 @@ export function loadAccounts(): Account[] { throw new Error('each account must have email and password strings') } } - // Filter out disabled accounts (enabled: false) - const allAccounts = parsed as Account[] - const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false) - return enabledAccounts + return parsed as Account[] } catch (error) { throw new Error(error as string) } diff --git a/src/util/Logger.ts b/src/util/Logger.ts index a0a00f1..ce8b76c 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -5,7 +5,9 @@ import { Ntfy } from './Ntfy' import { loadConfig } from './Load' import { DISCORD } from '../constants' -const DEFAULT_LIVE_LOG_USERNAME = 'MS Rewards - Live Logs' +// Avatar URL for webhook (consistent with ConclusionWebhook) +const WEBHOOK_AVATAR_URL = 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png' +const WEBHOOK_USERNAME = 'MS Rewards - Live Logs' type WebhookBuffer = { lines: string[] @@ -15,41 +17,18 @@ type WebhookBuffer = { const webhookBuffers = new Map() -// Periodic cleanup of old/idle webhook buffers to prevent memory leaks -setInterval(() => { - const now = Date.now() - const BUFFER_MAX_AGE_MS = 3600000 // 1 hour - - for (const [url, buf] of webhookBuffers.entries()) { - if (!buf.sending && buf.lines.length === 0) { - const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0 - if (now - lastActivity > BUFFER_MAX_AGE_MS) { - webhookBuffers.delete(url) - } - } - } -}, 600000) // Check every 10 minutes - function getBuffer(url: string): WebhookBuffer { let buf = webhookBuffers.get(url) if (!buf) { buf = { lines: [], sending: false } webhookBuffers.set(url, buf) } - // Track last activity for cleanup - (buf as unknown as { lastActivity: number }).lastActivity = Date.now() return buf } async function sendBatch(url: string, buf: WebhookBuffer) { if (buf.sending) return buf.sending = true - - // Load config to get webhook settings - const configData = loadConfig() - const webhookUsername = configData.webhook?.username || DEFAULT_LIVE_LOG_USERNAME - const webhookAvatarUrl = configData.webhook?.avatarUrl || DISCORD.AVATAR_URL - while (buf.lines.length > 0) { const chunk: string[] = [] let currentLength = 0 @@ -69,8 +48,8 @@ async function sendBatch(url: string, buf: WebhookBuffer) { // Enhanced webhook payload with embed, username and avatar const payload = { - username: webhookUsername, - avatar_url: webhookAvatarUrl, + username: WEBHOOK_USERNAME, + avatar_url: WEBHOOK_AVATAR_URL, embeds: [{ description: `\`\`\`\n${content}\n\`\`\``, color: determineColorFromContent(content), diff --git a/src/util/UserAgent.ts b/src/util/UserAgent.ts index 11d169a..6511cfb 100644 --- a/src/util/UserAgent.ts +++ b/src/util/UserAgent.ts @@ -172,10 +172,9 @@ async function fetchEdgeVersionsOnce(isMobile: boolean): Promise { - let timeoutHandle: NodeJS.Timeout | undefined try { const controller = new AbortController() - timeoutHandle = setTimeout(() => controller.abort(), 10000) + const timeout = setTimeout(() => controller.abort(), 10000) const response = await fetch(EDGE_VERSION_URL, { headers: { 'Content-Type': 'application/json', @@ -183,15 +182,13 @@ async function tryNativeFetchFallback(isMobile: boolean): Promise { - // Safety check: prevent extremely long or negative waits - const MAX_WAIT_MS = 3600000 // 1 hour max - const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS) - - if (ms !== safeMs) { - console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`) - } - return new Promise((resolve) => { - setTimeout(resolve, safeMs) + setTimeout(resolve, ms) }) } @@ -41,17 +33,7 @@ export default class Util { } chunkArray(arr: T[], numChunks: number): T[][] { - // 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 chunkSize = Math.ceil(arr.length / numChunks) const chunks: T[][] = [] for (let i = 0; i < arr.length; i += chunkSize) { @@ -70,13 +52,4 @@ export default class Util { return milisec } - // Internal: decode base64 metadata (for advanced feature discovery) - private _d(s: string): string { - try { - return Buffer.from(s, 'base64').toString('utf-8') - } catch { - return '' - } - } - } \ No newline at end of file