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

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

4
.gitignore vendored
View File

@@ -4,13 +4,9 @@ node_modules/
.vscode/
.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

110
README.md
View File

@@ -17,7 +17,7 @@
<br>
![Version](https://img.shields.io/badge/v2.4.0-blue?style=for-the-badge&logo=github&logoColor=white)
![Version](https://img.shields.io/badge/v2.3.0-blue?style=for-the-badge&logo=github&logoColor=white)
![License](https://img.shields.io/badge/ISC-00D9FF?style=for-the-badge)
![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 @@
<br>
---
<div align="center">
### 📌 **Update Notice**
Recent updates changed the structure of `config.jsonc` and `accounts.jsonc` files (including extensions).
**If you see Git conflicts during `git pull` on these files:**
```bash
# Delete and fresh clone
rm -rf Microsoft-Rewards-Script
git clone -b v2 https://github.com/TheNetsky/Microsoft-Rewards-Script.git
cd Microsoft-Rewards-Script
# Manually re-enter your settings in the new files
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ 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.
</div>
---
<br>
## What Does This Do?
<div align="center">
**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** |
</div>
<br>
## 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
<br>
## Intelligent Features
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ INTELLIGENT FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<table>
<tr>
@@ -173,7 +159,11 @@ Pre-flight checks
<br>
## Usage Commands
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ USAGE COMMANDS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
```bash
# Run automation once
@@ -194,7 +184,11 @@ npm start -- --dry-run
<br>
## Configuration
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONFIGURATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
Edit `src/config.jsonc` to customize behavior:
@@ -226,7 +220,11 @@ Edit `src/config.jsonc` to customize behavior:
<br>
## Core Features
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CORE FEATURES ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -271,7 +269,11 @@ Edit `src/config.jsonc` to customize behavior:
<br>
## Documentation
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ DOCUMENTATION ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -288,7 +290,11 @@ Edit `src/config.jsonc` to customize behavior:
<br>
## Technical Architecture
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ TECHNICAL ARCHITECTURE ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -321,7 +327,11 @@ Edit `src/config.jsonc` to customize behavior:
<br>
## Important Disclaimers
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ IMPORTANT DISCLAIMERS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -355,7 +365,11 @@ This project is for **educational purposes only**.
<br>
## Contributors
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ CONTRIBUTORS ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -399,7 +413,11 @@ This project is for **educational purposes only**.
<br>
## Community & Support
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ COMMUNITY & SUPPORT ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">
@@ -424,13 +442,11 @@ GitHub Issues are also available for documentation and tracking.
<br>
> 💡 **Looking for enhanced builds?** Community-maintained versions with faster updates and advanced features may be available. Ask in our Discord for more info.
</div>
<br>
## License
```
╔══════════════════════════════════════════════════════════════════════════════╗
║ LICENSE ║
╚══════════════════════════════════════════════════════════════════════════════╝
```
<div align="center">

View File

@@ -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
```
<EFBFBD> Account Details
✅ user1@example.com
└ Points: +720 (🖥️ 450 • 📱 270)
└ Duration: 7m 23s
✅ user2@example.com
└ Points: +940 (🖥️ 540 • 📱 400)
└ Duration: 9m 41s
✅ user3@example.com
└ Points: +0 (🖥️ 0 • 📱 0)
└ Duration: 8m 32s
```
**Color coding:**
- 🟢 **Green** — All accounts successful
- 🟠 **Orange** — Some accounts with errors
- 🔴 **Red** — Banned accounts detected
---
## 🎯 Advanced: Separate Channels
@@ -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

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

@@ -0,0 +1,148 @@
# 🚀 Getting Started
**From zero to your first run in 10 minutes**
---
## ✅ Requirements
- **Node.js 20+** → [Download here](https://nodejs.org/)
- **Microsoft accounts** with email + password
- *Optional:* Docker for containers
---
## ⚡ Quick Setup (Recommended)
### Windows
```powershell
setup\setup.bat
```
### Linux / macOS
```bash
bash setup/setup.sh
```
### What Does It Do?
1. ✅ Asks for your Microsoft credentials
2. ✅ Creates `accounts.json` automatically
3. ✅ Installs dependencies
4. ✅ Builds the project
5. ✅ Runs your first automation (optional)
**That's it! 🎉**
---
## 🎯 After Installation
### 1⃣ Enable Scheduler (Recommended)
Run automatically once per day:
**Edit** `src/config.jsonc`:
```jsonc
{
"schedule": {
"enabled": true,
"time": "09:00",
"timeZone": "America/New_York"
}
}
```
**Start scheduler:**
```bash
npm run start:schedule
```
**[Full Scheduler Guide](./schedule.md)**
---
### 2⃣ Add Notifications (Optional)
Get a summary after each run:
```jsonc
{
"conclusionWebhook": {
"enabled": true,
"url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_URL"
}
}
```
**[Discord Setup](./conclusionwebhook.md)** | **[NTFY Setup](./ntfy.md)**
---
### 3⃣ Enable Humanization (Anti-Ban)
More natural behavior:
```jsonc
{
"humanization": {
"enabled": true
}
}
```
**[Humanization Guide](./humanization.md)**
---
## 🛠️ Common Issues
| Problem | Solution |
|---------|----------|
| **"Node.js not found"** | Install Node.js 20+ and restart terminal |
| **"accounts.json missing"** | Run `setup/setup.bat` or create manually |
| **"Login failed"** | Check email/password in `accounts.json` |
| **"2FA prompt"** | Add TOTP secret → [2FA Guide](./accounts.md) |
| **Script crashes** | Check [Diagnostics Guide](./diagnostics.md) |
---
## 🔧 Manual Setup (Advanced)
<details>
<summary><strong>Click to expand</strong></summary>
```bash
# 1. Configure accounts
cp src/accounts.example.json src/accounts.json
# Edit accounts.json with your credentials
# 2. Install & build
npm install
npm run build
# 3. Run
npm start
```
</details>
---
## 📚 Next Steps
**Everything works?**
**[Setup Scheduler](./schedule.md)** for daily automation
**Need 2FA?**
**[Accounts & TOTP Guide](./accounts.md)**
**Want Docker?**
**[Docker Guide](./docker.md)**
**Having issues?**
**[Diagnostics](./diagnostics.md)**
---
**[← Back to Hub](./index.md)** | **[All Docs](./index.md)**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "microsoft-rewards-script",
"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",

View File

@@ -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 ."
},

View File

@@ -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%.

View File

@@ -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.');
}
}

View File

@@ -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}"

View File

@@ -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))

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

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

View File

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

View File

@@ -54,8 +54,7 @@ class Browser {
const engineName = 'chromium' // current hard-coded engine
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')
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
const inputPromise = new Promise<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (checkInterval) clearInterval(checkInterval)
rl.close()
res(ans.trim())
})
})
// Check every 2 seconds if user manually progressed past the dialog
checkInterval = setInterval(async () => {
try {
await this.bot.browser.utils.tryDismissAllMessages(page)
// Check if we're no longer on 2FA page
const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
if (!still2FA) {
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
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<string | null> {
@@ -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 */}
}

View File

@@ -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<void>((resolve) => {
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit', env })
const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' })
child.on('close', () => resolve())
child.on('error', () => resolve())
})
@@ -1143,13 +1146,24 @@ export class MicrosoftRewardsBot {
private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> {
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')
}

View File

@@ -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) */

View File

@@ -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 {

View File

@@ -13,12 +13,6 @@ type DateTimeInstance = ReturnType<typeof DateTime.fromJSDate>
function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } {
const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
// Warn if an invalid timezone was provided
if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) {
void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn')
}
// Determine source string
let src = ''
if (typeof schedule?.useAmPm === 'boolean') {
@@ -120,28 +114,13 @@ async function runOnePassWithWatchdog(): Promise<void> {
// Heartbeat-aware watchdog configuration
// If a child is actively updating its heartbeat file, we allow it to run beyond the legacy timeout.
// Defaults are generous to allow first-day passes to finish searches with delays.
const 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<void> {
let finished = false
const startedAt = Date.now()
let killTimeout: NodeJS.Timeout | undefined
const killChild = async (signal: NodeJS.Signals) => {
try {
await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn')
@@ -185,8 +162,7 @@ async function runOnePassWithWatchdog(): Promise<void> {
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<void> {
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<void> {
child.on('error', async (err) => {
finished = true
clearInterval(timer)
if (killTimeout) clearTimeout(killTimeout)
try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error')
resolve()
@@ -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)
})

View File

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

View File

@@ -24,14 +24,14 @@ class AxiosClient {
const { url, port } = proxyConfig
switch (true) {
case proxyConfig.url.startsWith('http://'):
case proxyConfig.url.startsWith('http'):
return new HttpProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('https://'):
case proxyConfig.url.startsWith('https'):
return new HttpsProxyAgent(`${url}:${port}`)
case proxyConfig.url.startsWith('socks://') || 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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

View File

@@ -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<string>()
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<string>()
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
if (hasWebhook) urls.add(config.webhook!.url)
await Promise.all(
Array.from(urls).map((url, index) => postWebhook(url, `conclusion-webhook-${index + 1}`))
)
// Optional NTFY notification (simplified summary)
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
const message = [
`🎯 Microsoft Rewards Summary`,
`Status: ${statusText}`,
`Points: ${data.totalInitial}${data.totalEnd} (+${data.totalCollected})`,
`Accounts: ${data.successes}/${data.totalAccounts} successful`,
`Duration: ${formatDuration(data.totalDuration)}`
].join('\n')
const ntfyType = embedColor === DISCORD.COLOR_RED ? 'error' : embedColor === DISCORD.COLOR_ORANGE ? 'warn' : 'log'
try {
await Ntfy(message, ntfyType)
log('main', 'NTFY', 'Conclusion notification sent successfully')
} catch (error) {
log('main', 'NTFY', `Failed to send conclusion notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
console.log('Conclusion summary sent to NTFY.')
} catch (err) {
console.error('Failed to send conclusion summary to NTFY:', err)
}
}
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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<string, WebhookBuffer>()
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
setInterval(() => {
const now = Date.now()
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
for (const [url, buf] of webhookBuffers.entries()) {
if (!buf.sending && buf.lines.length === 0) {
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
if (now - lastActivity > BUFFER_MAX_AGE_MS) {
webhookBuffers.delete(url)
}
}
}
}, 600000) // Check every 10 minutes
function getBuffer(url: string): WebhookBuffer {
let buf = webhookBuffers.get(url)
if (!buf) {
buf = { lines: [], sending: false }
webhookBuffers.set(url, buf)
}
// Track last activity for cleanup
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
return buf
}
async function sendBatch(url: string, buf: WebhookBuffer) {
if (buf.sending) return
buf.sending = true
// Load config to get webhook settings
const configData = loadConfig()
const webhookUsername = configData.webhook?.username || DEFAULT_LIVE_LOG_USERNAME
const webhookAvatarUrl = configData.webhook?.avatarUrl || DISCORD.AVATAR_URL
while (buf.lines.length > 0) {
const chunk: string[] = []
let currentLength = 0
@@ -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),

View File

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

View File

@@ -3,16 +3,8 @@ import ms from 'ms'
export default class Util {
async wait(ms: number): Promise<void> {
// Safety check: prevent extremely long or negative waits
const MAX_WAIT_MS = 3600000 // 1 hour max
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
if (ms !== safeMs) {
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
}
return new Promise<void>((resolve) => {
setTimeout(resolve, safeMs)
setTimeout(resolve, ms)
})
}
@@ -41,17 +33,7 @@ export default class Util {
}
chunkArray<T>(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 ''
}
}
}