diff --git a/.gitignore b/.gitignore index af3a92c..974d44d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,7 @@ node_modules/ diagnostic/ report/ accounts.json -accounts.jsonc notes accounts.dev.json -accounts.dev.jsonc -accounts.main.json -accounts.main.jsonc .DS_Store .playwright-chromium-installed \ No newline at end of file diff --git a/NOTICE b/NOTICE deleted file mode 100644 index bbfd3d6..0000000 --- a/NOTICE +++ /dev/null @@ -1,128 +0,0 @@ -# IMPORTANT LEGAL NOTICES - -## 🚨 Terms of Service Violation Warning - -**Using this software violates Microsoft's Terms of Service.** - -Microsoft Rewards explicitly prohibits: -- Automated point collection -- Bot usage for completing tasks -- Any form of automation on their platform - -### Potential Consequences: -- ❌ **Immediate account suspension** -- ❌ **Permanent ban from Microsoft Rewards** -- ❌ **Forfeiture of all accumulated points** -- ❌ **Loss of redemption history** -- ⚠️ Possible restrictions on other Microsoft services - ---- - -## 🏒 Commercial Use Prohibited - -This software is licensed for **PERSONAL, NON-COMMERCIAL USE ONLY**. - -### ❌ Prohibited Activities: -- Selling this software or modifications -- Offering it as a paid service -- Using it for business purposes -- Creating commercial automation services -- Bulk account management for profit -- Integration into commercial platforms - -### βœ… Permitted Activities: -- Personal use for your own accounts -- Educational purposes and learning -- Private modifications for personal use -- Sharing with family/friends (non-commercial) - ---- - -## βš–οΈ Legal Disclaimer - -### No Warranty -This software is provided "AS IS" without any warranty of any kind. - -### No Liability -The authors and contributors: -- Are NOT responsible for account suspensions -- Are NOT responsible for lost points or rewards -- Are NOT responsible for any damages -- Do NOT encourage ToS violations -- Provide this for educational purposes ONLY - -### Your Responsibility -You are solely responsible for: -- Your use of this software -- Compliance with Microsoft's policies -- Any consequences from automation -- Legal implications in your jurisdiction - ---- - -## πŸŽ“ Educational Purpose Statement - -This project is developed and maintained for **educational purposes**: -- To demonstrate browser automation techniques -- To showcase TypeScript and Playwright capabilities -- To teach software architecture patterns -- To explore anti-detection methodologies - -**The authors do not condone using this software in violation of any Terms of Service.** - ---- - -## πŸ”’ Privacy & Security - -### Your Data: -- This software stores credentials **locally only** -- No data is sent to third parties -- Sessions are stored in the `sessions/` folder -- You can delete all data by removing local files - -### Third-Party Services: -- Google Trends (for search queries) -- Bing Search (for automation) -- Discord/NTFY (optional, for notifications) - -### Your Responsibility: -- Protect your `accounts.json` file -- Use strong passwords -- Enable 2FA where possible -- Don't share your configuration publicly - ---- - -## 🌍 Geographic Restrictions - -Microsoft Rewards availability and terms vary by region: -- Available in select countries only -- Region-specific earning rates -- Local laws may apply -- Check your local regulations - -**By using this software, you confirm:** -1. Microsoft Rewards is available in your region -2. You understand the risks of automation -3. You accept full responsibility for your actions -4. You will not use this for commercial purposes - ---- - -## πŸ“ž Contact & Reporting - -**Questions about licensing?** -Open an issue at: https://github.com/TheNetsky/Microsoft-Rewards-Script/issues - -**Found a security issue?** -See: SECURITY.md - -**General discussion?** -Join Discord: https://discord.gg/KRBFxxsU - ---- - -**Last Updated:** October 2025 -**Applies to:** Microsoft Rewards Script V2.1.5 and later - -**BY USING THIS SOFTWARE, YOU ACKNOWLEDGE THAT YOU HAVE READ AND UNDERSTOOD THESE NOTICES.** diff --git a/README.md b/README.md index 153cca2..033742d 100644 --- a/README.md +++ b/README.md @@ -2,208 +2,378 @@ # Quick Setup (Recommended) -**Easiest way to get started β€” download and run:** - -1. **Clone the branch** or download the zip. -2. **Run the setup script:** - - * **Windows:** double-click `setup/setup.bat` or run it from a command prompt - * **Linux / macOS / WSL:** +1. Clone this repository or download the latest release ZIP. +2. Run the setup script: + * **Windows:** + Double-click `setup/setup.bat` + * **Linux / macOS / WSL:** ```bash bash setup/setup.sh ``` - * **Alternative (any platform):** - + * **Alternative (any platform):** ```bash npm run setup ``` -3. **Follow the setup prompts.** The script will: - * Rename `accounts.example.json` β†’ `accounts.json` - * Ask for Microsoft account credentials +3. Follow the prompts β€” the setup script will: + * Copy `accounts.example.json` β†’ `accounts.json` + * Ask for your Microsoft account credentials * Remind you to review `config.json` * Install dependencies (`npm install`) - * Build the project (`npm run build`) + * Build (`npm run build`) * Optionally start the script -**That's it β€” the setup script handles the rest.** +That's it β€” the setup script handles the rest. --- # Advanced Setup Options ### Nix Users - -1. Install Nix from [https://nixos.org/](https://nixos.org/) -2. Run: - +If using Nix: ```bash ./run.sh ``` ### Manual Setup (if setup script fails) - -1. Copy `src/accounts.example.json` β†’ `src/accounts.json` and add accounts. -2. Edit `src/config.json` as needed. -3. Install dependencies: - -```bash -npm install -``` - -4. Build: - -```bash -npm run build -``` - -5. Start: - -```bash -npm run start -``` +1. Copy: + ```bash + cp src/accounts.example.json src/accounts.json + ``` +2. Edit `src/accounts.json` and `src/config.json`. +3. Install and build: + ```bash + npm install + npm run build + npm run start + ``` --- -# Docker Setup (Experimental) +# Docker Setup (Recommended for Scheduling) -**Before starting** +## Before Starting +* Remove local `/node_modules` and `/dist` if previously built. +* Remove old Docker volumes if upgrading from older versions. +* You can reuse your old `accounts.json`. -* Remove local `/node_modules` and `/dist` if you previously built. -* Remove old Docker volumes when upgrading from v1.4 or earlier. -* You can reuse older `accounts.json`. - -**Quick Docker (recommended for scheduling)** - -1. Clone v2 and configure `accounts.json`. -2. Ensure `config.json` has `"headless": true`. +## Quick Start +1. Clone v2 and configure `accounts.json` +2. Ensure `config.json` has `"headless": true` 3. Edit `compose.yaml`: - - * Set `TZ` (timezone) - * Set `CRON_SCHEDULE` (use crontab.guru for help) + * Set your timezone (`TZ`) + * Set cron schedule (`CRON_SCHEDULE`) * Optional: `RUN_ON_START=true` 4. Start: + ```bash + docker compose up -d + ``` +5. Monitor logs: + ```bash + docker logs microsoft-rewards-script + ``` -```bash -docker compose up -d +The container randomly delays scheduled runs by approximately 5–50 minutes to appear more natural (configurable, see notes below). + +## Example compose.yaml + +```yaml +services: + microsoft-rewards-script: + image: ghcr.io/your-org/microsoft-rewards-script:latest + container_name: microsoft-rewards-script + restart: unless-stopped + + # Mount your configuration and persistent session storage + volumes: + # Read-only config files from your working directory into the container + - ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro + - ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro + + # Persist browser sessions/fingerprints between runs + - ./sessions:/usr/src/microsoft-rewards-script/dist/sessions + + # Optional: persist job state directory (if you set jobState.dir to a folder inside dist/) + # - ./jobstate:/usr/src/microsoft-rewards-script/dist/jobstate + + environment: + # Timezone for scheduling + TZ: "Europe/Amsterdam" + + # Node runtime + NODE_ENV: "production" + + # Cron schedule for automatic runs (UTC inside container) + # Example: run at 07:00, 16:00, and 20:00 every day + CRON_SCHEDULE: "0 7,16,20 * * *" + + # Run immediately on container start (in addition to CRON_SCHEDULE) + RUN_ON_START: "true" + + # Randomize scheduled start-time between MIN..MAX minutes + # Comment these to use defaults (about 5–50 minutes) + # MIN_SLEEP_MINUTES: "5" + # MAX_SLEEP_MINUTES: "50" + + # Optional: disable randomization entirely + # SKIP_RANDOM: "true" + + # Optional: limit resources if desired + deploy: + resources: + limits: + cpus: "1.0" + memory: "1g" ``` -5. Monitor: - -```bash -docker logs microsoft-rewards-script -``` - -> The container randomly delays scheduled runs by ~5–50 minutes to appear more natural. - ---- - -# Usage Notes - -* **Headless=false cleanup:** If you stop the script without closing browser windows, use Task Manager / `npm run kill-chrome-win` to close leftover instances. -* **Scheduling advice:** Run at least twice daily. Use `"runOnZeroPoints": false` in config to skip runs with no points. -* **Multiple accounts:** Use `clusters` in `config.json` to run accounts in parallel. +### compose.yaml Notes +- `volumes` + - `accounts.json` and `config.json` are mounted read-only to avoid accidental in-container edits. Edit them on the host. + - `sessions` persists your login sessions and fingerprints across restarts and updates. + - If you enable `jobState.enabled` and set `jobState.dir`, consider mounting that path as a volume too. +- `CRON_SCHEDULE` + - Standard crontab format. Use a site like crontab.guru to generate expressions. + - The schedule is evaluated inside the container; ensure `TZ` matches your desired timezone. +- `RUN_ON_START` + - If `"true"`, the script runs once immediately when the container is started, then on the cron schedule. +- Randomization + - By default, a randomized delay prevents runs from happening at exactly the same time every day. + - You can tune it with `MIN_SLEEP_MINUTES` and `MAX_SLEEP_MINUTES`, or disable with `SKIP_RANDOM`. --- # Configuration Reference -Edit `src/config.json` to customize behavior. +Edit `src/config.json` to customize the bot’s behavior. -### Core Settings (examples) +## Core -| Setting | Description | Default | -| ----------------- | -------------------------------: | -------------------------: | -| `baseURL` | Microsoft Rewards URL | `https://rewards.bing.com` | -| `sessionPath` | Session/fingerprint storage | `sessions` | -| `headless` | Run browser in background | `false` | -| `parallel` | Run mobile/desktop tasks at once | `true` | -| `runOnZeroPoints` | Run when no points available | `false` | -| `clusters` | Concurrent account instances | `1` | - -### Fingerprint Settings - -| Setting | Description | Default | -| ------------------------- | ------------------------: | ------: | -| `saveFingerprint.mobile` | Reuse mobile fingerprint | `false` | -| `saveFingerprint.desktop` | Reuse desktop fingerprint | `false` | - -### Task Settings (important ones) - -| Setting | Description | Default | -| -------------------------- | -----------------: | ------: | -| `workers.doDailySet` | Do daily set | `true` | -| `workers.doMorePromotions` | Promotional offers | `true` | -| `workers.doPunchCards` | Punchcard tasks | `true` | -| `workers.doDesktopSearch` | Desktop searches | `true` | -| `workers.doMobileSearch` | Mobile searches | `true` | -| `workers.doDailyCheckIn` | Daily check-in | `true` | -| `workers.doReadToEarn` | Read-to-earn | `true` | - -### Search Settings - -| Setting | Description | Default | -| ---------------------------------------- | ---------------------: | ------------: | -| `searchOnBingLocalQueries` | Use local queries | `false` | -| `searchSettings.useGeoLocaleQueries` | Geo-based queries | `false` | -| `searchSettings.scrollRandomResults` | Random scrolling | `true` | -| `searchSettings.clickRandomResults` | Random link clicks | `true` | -| `searchSettings.searchDelay` | Delay between searches | `3-5 minutes` | -| `searchSettings.retryMobileSearchAmount` | Mobile retry attempts | `2` | - -### Advanced Settings - -| Setting | Description | Default | -| ------------------------- | --------------------------: | ------------------: | -| `globalTimeout` | Action timeout | `30s` | -| `logExcludeFunc` | Exclude functions from logs | `SEARCH-CLOSE-TABS` | -| `proxy.proxyGoogleTrends` | Proxy Google Trends | `true` | -| `proxy.proxyBingTerms` | Proxy Bing Terms | `true` | - -### Webhook Settings - -| Setting | Description | Default | -| --------------------------- | ---------------------------: | ------: | -| `webhook.enabled` | Enable Discord notifications | `false` | -| `webhook.url` | Discord webhook URL | `null` | -| `conclusionWebhook.enabled` | Summary-only webhook | `false` | -| `conclusionWebhook.url` | Summary webhook URL | `null` | +| Setting | Description | Default | +|----------|-------------|----------| +| `baseURL` | Microsoft Rewards base URL | `https://rewards.bing.com` | +| `sessionPath` | Folder to store browser sessions | `sessions` | +| `dryRun` | Simulate without running tasks | `false` | --- -# Features +## Browser -**Account & Session** +| Setting | Description | Default | +|----------|-------------|----------| +| `browser.headless` | Run browser invisibly | `false` | +| `browser.globalTimeout` | Timeout for actions | `"30s"` | -* Multi-account support -* Persistent sessions & fingerprints -* 2FA support & passwordless options +--- -**Automation** +## Fingerprinting -* Headless operation & clustering -* Selectable task sets -* Proxy support & scheduling (Docker) +| Setting | Description | Default | +|----------|-------------|----------| +| `fingerprinting.saveFingerprint.mobile` | Reuse mobile fingerprint | `true` | +| `fingerprinting.saveFingerprint.desktop` | Reuse desktop fingerprint | `true` | -**Search & Rewards** +--- -* Desktop & mobile searches -* Emulated browsing, scrolling, clicks -* Daily sets, promotions, punchcards, quizzes +## Execution -**Interactions** +| Setting | Description | Default | +|----------|-------------|----------| +| `execution.parallel` | Run desktop and mobile at once | `false` | +| `execution.runOnZeroPoints` | Run even with no points | `false` | +| `execution.clusters` | Concurrent account clusters | `1` | -* Quiz solving (10 & 30–40 point variants) -* Polls, ABC quizzes, β€œThis or That” answers +--- -**Notifications** +## Job State -* Discord webhooks and summary webhooks -* Extensive logs for debugging +| Setting | Description | Default | +|----------|-------------|----------| +| `jobState.enabled` | Save last job state | `true` | +| `jobState.dir` | Directory for job data | `""` | + +--- + +## Workers (Tasks) + +| Setting | Description | Default | +|----------|-------------|----------| +| `doDailySet` | Complete daily set | `true` | +| `doMorePromotions` | Complete more promotions | `true` | +| `doPunchCards` | Complete punchcards | `true` | +| `doDesktopSearch` | Perform desktop searches | `true` | +| `doMobileSearch` | Perform mobile searches | `true` | +| `doDailyCheckIn` | Complete daily check-in | `true` | +| `doReadToEarn` | Complete Read-to-Earn | `true` | +| `bundleDailySetWithSearch` | Combine daily set and searches | `true` | + +--- + +## Search + +| Setting | Description | Default | +|----------|-------------|----------| +| `search.useLocalQueries` | Use local query list | `true` | +| `search.settings.useGeoLocaleQueries` | Use region-based queries | `true` | +| `search.settings.scrollRandomResults` | Random scrolling | `true` | +| `search.settings.clickRandomResults` | Random link clicking | `true` | +| `search.settings.retryMobileSearchAmount` | Retry mobile searches | `2` | +| `search.settings.delay.min` | Minimum delay between searches | `1min` | +| `search.settings.delay.max` | Maximum delay between searches | `5min` | + +--- + +## Query Diversity + +| Setting | Description | Default | +|----------|-------------|----------| +| `queryDiversity.enabled` | Enable multiple query sources | `true` | +| `queryDiversity.sources` | Query providers | `["google-trends", "reddit", "local-fallback"]` | +| `queryDiversity.maxQueriesPerSource` | Limit per source | `10` | +| `queryDiversity.cacheMinutes` | Cache lifetime | `30` | + +--- + +## Humanization + +| Setting | Description | Default | +|----------|-------------|----------| +| `humanization.enabled` | Enable human behavior | `true` | +| `stopOnBan` | Stop immediately on ban | `true` | +| `immediateBanAlert` | Alert instantly if banned | `true` | +| `actionDelay.min` | Minimum delay per action (ms) | `500` | +| `actionDelay.max` | Maximum delay per action (ms) | `2200` | +| `gestureMoveProb` | Chance of random mouse movement | `0.65` | +| `gestureScrollProb` | Chance of random scrolls | `0.4` | + +--- + +## Vacation Mode + +| Setting | Description | Default | +|----------|-------------|----------| +| `vacation.enabled` | Enable random pauses | `true` | +| `minDays` | Minimum days off | `2` | +| `maxDays` | Maximum days off | `4` | + +--- + +## Risk Management + +| Setting | Description | Default | +|----------|-------------|----------| +| `enabled` | Enable risk-based adjustments | `true` | +| `autoAdjustDelays` | Adapt delays dynamically | `true` | +| `stopOnCritical` | Stop on critical warning | `false` | +| `banPrediction` | Predict bans based on signals | `true` | +| `riskThreshold` | Risk tolerance level | `75` | + +--- + +## Retry Policy + +| Setting | Description | Default | +|----------|-------------|----------| +| `maxAttempts` | Maximum retry attempts | `3` | +| `baseDelay` | Initial retry delay | `1000` | +| `maxDelay` | Maximum retry delay | `30s` | +| `multiplier` | Backoff multiplier | `2` | +| `jitter` | Random jitter factor | `0.2` | + +--- + +## Proxy + +| Setting | Description | Default | +|----------|-------------|----------| +| `proxy.proxyGoogleTrends` | Proxy Google Trends | `true` | +| `proxy.proxyBingTerms` | Proxy Bing Terms | `true` | + +--- + +## Notifications + +| Setting | Description | Default | +|----------|-------------|----------| +| `notifications.webhook.enabled` | Enable Discord webhook | `false` | +| `notifications.webhook.url` | Discord webhook URL | `""` | +| `notifications.conclusionWebhook.enabled` | Enable summary webhook | `false` | +| `notifications.conclusionWebhook.url` | Summary webhook URL | `""` | +| `notifications.ntfy.enabled` | Enable Ntfy push alerts | `false` | +| `notifications.ntfy.url` | Ntfy server URL | `""` | +| `notifications.ntfy.topic` | Ntfy topic name | `"rewards"` | + +--- + +## Logging + +| Setting | Description | Default | +|----------|-------------|----------| +| `excludeFunc` | Exclude from console logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` | +| `webhookExcludeFunc` | Exclude from webhook logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` | +| `redactEmails` | Hide emails in logs | `true` | + +--- + +# Account Configuration + +Edit `src/accounts.json`: + +```json +{ + "accounts": [ + { + "enabled": true, + "email": "email_1@outlook.com", + "password": "password_1", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + "enabled": false, + "email": "email_2@outlook.com", + "password": "password_2", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + } + ] +} +``` + +--- + +# Features Overview + +- Multi-account and session handling +- Persistent browser fingerprints +- Parallel task execution +- Proxy and retry support +- Human-like delays and scrolling +- Full daily set automation +- Mobile and desktop search support +- Vacation and risk protection +- Webhook and Ntfy notifications +- Docker scheduling support --- # Disclaimer -**Use at your own risk.** Automation may cause suspension or banning of Microsoft Rewards accounts. This project is provided for educational purposes only. The maintainers are **not** responsible for account actions taken by Microsoft. +Use at your own risk. +Automation of Microsoft Rewards may lead to account suspension or bans. +This software is provided for educational purposes only. +The authors are not responsible for any actions taken by Microsoft. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 844b310..eaa0ccf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,8 @@ services: # Volume mounts: Specify a location where you want to save the files on your local machine. volumes: - - ./src/accounts.jsonc:/usr/src/microsoft-rewards-script/dist/accounts.json:ro - - ./src/config.jsonc:/usr/src/microsoft-rewards-script/dist/config.json:ro + - ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro + - ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro - ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session environment: diff --git a/flake.nix b/flake.nix index fd0f08d..e327534 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ playwright-test # fixes "waiting until load" issue compared to - # setting headless in config.jsonc + # setting headless in config.json xvfb-run ]; diff --git a/package-lock.json b/package-lock.json index 1656727..4469c71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.4.1", "license": "ISC", "dependencies": { - "axios": "^1.8.4", + "axios": "^1.13.2", "chalk": "^4.1.2", "cheerio": "^1.0.0", - "cron-parser": "^4.9.0", - "fingerprint-generator": "^2.1.66", - "fingerprint-injector": "^2.1.66", + "cron-parser": "^5.4.0", + "fingerprint-generator": "^2.1.76", + "fingerprint-injector": "^2.1.76", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "luxon": "^3.5.0", @@ -316,6 +316,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -584,6 +585,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", "engines": { "node": ">=12.0" } @@ -661,9 +663,10 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -677,9 +680,10 @@ "dev": true }, "node_modules/baseline-browser-mapping": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", - "integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==", + "version": "2.8.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", + "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -711,9 +715,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "funding": [ { "type": "opencollective", @@ -728,12 +732,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -763,9 +768,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001746", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", - "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "funding": [ { "type": "opencollective", @@ -779,7 +784,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -875,15 +881,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.4.0.tgz", + "integrity": "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==", "license": "MIT", "dependencies": { - "luxon": "^3.2.1" + "luxon": "^3.7.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=18" } }, "node_modules/cross-spawn": { @@ -1043,6 +1049,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", "dependencies": { "is-obj": "^2.0.0" }, @@ -1073,9 +1080,10 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.227", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", - "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==" + "version": "1.5.249", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", + "integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==", + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -1151,6 +1159,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -1440,12 +1449,13 @@ } }, "node_modules/fingerprint-generator": { - "version": "2.1.74", - "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.74.tgz", - "integrity": "sha512-GWaUwAiy0hn1EjEG0/aQbjTTISdPN94hWwXwb5riVgvli4+XotLtkIr8aEKBYWb3FcqyW3AbSYgn5OAtPKWLSw==", + "version": "2.1.76", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.76.tgz", + "integrity": "sha512-nynXZnqCBtBbEgqqdHS5mGm+R9JRRJxNun+lpZlCxGVt0BzgQJGibOvYCe5I54hIIVsaTldZ+jOb4btRPfPD6g==", + "license": "Apache-2.0", "dependencies": { - "generative-bayesian-network": "^2.1.74", - "header-generator": "^2.1.74", + "generative-bayesian-network": "^2.1.76", + "header-generator": "^2.1.76", "tslib": "^2.4.0" }, "engines": { @@ -1453,11 +1463,12 @@ } }, "node_modules/fingerprint-injector": { - "version": "2.1.74", - "resolved": "https://registry.npmjs.org/fingerprint-injector/-/fingerprint-injector-2.1.74.tgz", - "integrity": "sha512-lTeueOgY0TIT8XGzqFI7IY9pR6wHRxdn3t3PN8g6YNA9ga9A0hc9Ig5xXmi+PFsn/+U4TTtkpxfrWYxYKFGW8g==", + "version": "2.1.76", + "resolved": "https://registry.npmjs.org/fingerprint-injector/-/fingerprint-injector-2.1.76.tgz", + "integrity": "sha512-ySlMhYmj7D5ND92BHd/46b6g+izGkj1Vm8qTjSORC3o7HbdFr6eQz8JuL8pVmiJIsC2tsd2ohVvu584HRKurNQ==", + "license": "Apache-2.0", "dependencies": { - "fingerprint-generator": "^2.1.74", + "fingerprint-generator": "^2.1.76", "tslib": "^2.4.0" }, "engines": { @@ -1633,9 +1644,10 @@ } }, "node_modules/generative-bayesian-network": { - "version": "2.1.74", - "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.74.tgz", - "integrity": "sha512-AlEQVo1I6FWVObaF8SfPP+qssCv3Heu1Bdu1THMIkB5h4sP9E0AR9zo8qVL936IcdC3tckA4A/fJWhPia7GXgg==", + "version": "2.1.76", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.76.tgz", + "integrity": "sha512-e9BByo5UEXPsrOii4RM94a02y1JXhP5XZKbzC5GWDz62Bbh2jWbrkY0ta2cF1rxrv8pqLu4c98yQC2F50Eqa7A==", + "license": "Apache-2.0", "dependencies": { "adm-zip": "^0.5.9", "tslib": "^2.4.0" @@ -1823,12 +1835,13 @@ } }, "node_modules/header-generator": { - "version": "2.1.74", - "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.74.tgz", - "integrity": "sha512-0t4mRzBf3mtbwU+Clwm6sDvwEvOM5TIvCicFkYM5z48oR9DGLFPnVNKKOSJIDiJG08r1mY30ovI97JybJ6/Fug==", + "version": "2.1.76", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.76.tgz", + "integrity": "sha512-Lqk4zU/MIHkm29Sfle6E3Jo2gUoscoG9x12jDt1RbH3kRq/RN+NRSoRRYggmkI0GQSS0wiOIfWwjgIRrA9nHqA==", + "license": "Apache-2.0", "dependencies": { "browserslist": "^4.21.1", - "generative-bayesian-network": "^2.1.74", + "generative-bayesian-network": "^2.1.76", "ow": "^0.28.1", "tslib": "^2.4.0" }, @@ -1869,6 +1882,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -2002,6 +2016,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", "engines": { "node": ">=8" } @@ -2107,7 +2122,8 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead." + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -2213,7 +2229,8 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -2222,9 +2239,10 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", - "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==" + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" }, "node_modules/nth-check": { "version": "2.1.1", @@ -2267,6 +2285,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.2.0", "callsites": "^3.1.0", @@ -2429,7 +2448,8 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -2935,9 +2955,9 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "funding": [ { "type": "opencollective", @@ -2952,6 +2972,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -2981,6 +3002,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 8f44519..bd3b1bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microsoft-rewards-script", - "version": "2.4.0", + "version": "2.4.1", "description": "Automatically do tasks for Microsoft Rewards but in TS!", "private": true, "main": "index.js", @@ -25,7 +25,6 @@ "dev": "ts-node ./src/index.ts -dev", "lint": "eslint \"src/**/*.{ts,tsx}\"", "prepare": "npm run build", - "setup": "node ./setup/update/setup.mjs", "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", "create-docker": "docker build -t microsoft-rewards-script-docker ." }, @@ -39,20 +38,10 @@ "Cheerio" ], "author": "Netsky", - "contributors": [ - "TheNetsky (https://github.com/TheNetsky)", - "LightZirconite (https://github.com/LightZirconite)", - "Mgrimace (https://github.com/mgrimace)", - "hmcdat (https://github.com/hmcdat)" - ], "license": "ISC", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/TheNetsky" - }, "devDependencies": { - "@types/node": "^20.14.11", "@types/ms": "^0.7.34", + "@types/node": "^20.14.11", "@typescript-eslint/eslint-plugin": "^7.17.0", "eslint": "^8.57.0", "eslint-plugin-modules-newline": "^0.0.6", @@ -60,19 +49,19 @@ "typescript": "^5.5.4" }, "dependencies": { - "axios": "^1.8.4", - "cron-parser": "^4.9.0", + "axios": "^1.13.2", "chalk": "^4.1.2", "cheerio": "^1.0.0", - "fingerprint-generator": "^2.1.66", - "fingerprint-injector": "^2.1.66", + "cron-parser": "^5.4.0", + "fingerprint-generator": "^2.1.76", + "fingerprint-injector": "^2.1.76", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", - "ms": "^2.1.3", "luxon": "^3.5.0", + "ms": "^2.1.3", "playwright": "1.52.0", "rebrowser-playwright": "1.52.0", "socks-proxy-agent": "^8.0.5", "ts-node": "^10.9.2" } -} +} \ No newline at end of file diff --git a/setup/update/setup.mjs b/setup/update/setup.mjs deleted file mode 100644 index bee1eda..0000000 --- a/setup/update/setup.mjs +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env node -/** - * Unified cross-platform setup script for Microsoft Rewards Script V2. - * - * Features: - * - Renames accounts.example.jsonc -> accounts.json (idempotent) - * - Guides user through account configuration (email, password, TOTP, proxy) - * - Explains config.jsonc structure and key settings - * - Installs dependencies (npm install) - * - Builds TypeScript project (npm run build) - * - Installs Playwright Chromium browser (idempotent with marker) - * - Optional immediate start or manual start instructions - * - * V2 Updates: - * - Enhanced prompts for new config.jsonc structure - * - Explains humanization, scheduling, notifications - * - References updated documentation (docs/config.md, docs/accounts.md) - * - Improved user guidance for first-time setup - */ - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -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, '..', '..'); -const SRC_DIR = path.join(PROJECT_ROOT, 'src'); - -function log(msg) { console.log(msg); } -function warn(msg) { console.warn(msg); } -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'); - if (fs.existsSync(accounts)) { - log('accounts.json already exists - skipping rename.'); - return; - } - if (fs.existsSync(example)) { - log('Renaming accounts.example.jsonc to accounts.json...'); - fs.renameSync(example, accounts); - } else { - warn('Neither accounts.json nor accounts.example.jsonc found.'); - } -} - -async function prompt(question) { - return await new Promise(resolve => { - process.stdout.write(question); - const onData = (data) => { - const ans = data.toString().trim(); - process.stdin.off('data', onData); - resolve(ans); - }; - process.stdin.on('data', onData); - }); -} - -async function loopForAccountsConfirmation() { - log('\nπŸ“ Please configure your Microsoft accounts:'); - log(' - Open: src/accounts.json'); - log(' - Add your email and password for each account'); - log(' - Optional: Add TOTP secret for 2FA (see docs/accounts.md)'); - log(' - Optional: Configure proxy settings per account'); - log(' - Save the file (Ctrl+S or Cmd+S)\n'); - - // Keep asking until user says yes - for (;;) { - const ans = (await prompt('Have you configured your accounts in accounts.json? (yes/no): ')).toLowerCase(); - if (['yes', 'y'].includes(ans)) break; - if (['no', 'n'].includes(ans)) { - log('Please configure accounts.json and save the file, then answer yes.'); - continue; - } - log('Please answer yes or no.'); - } -} - -function runCommand(cmd, args, opts = {}) { - return new Promise((resolve, reject) => { - log(`Running: ${cmd} ${args.join(' ')}`); - const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts }); - child.on('exit', (code) => { - if (code === 0) return resolve(); - reject(new Error(`${cmd} exited with code ${code}`)); - }); - }); -} - -async function ensureNpmAvailable() { - try { - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['-v']); - } catch (e) { - throw new Error('npm not found in PATH. Install Node.js first.'); - } -} - -async function startOnly() { - log('Starting program (npm run start)...'); - await ensureNpmAvailable(); - // Assume user already installed & built; if dist missing inform user. - const distIndex = path.join(PROJECT_ROOT, 'dist', 'index.js'); - if (!fs.existsSync(distIndex)) { - warn('Build output not found. Running build first.'); - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']); - await installPlaywrightBrowsers(); - } else { - // Even if build exists, ensure browsers are installed once. - await installPlaywrightBrowsers(); - } - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'start']); -} - -async function fullSetup() { - renameAccountsIfNeeded(); - await loopForAccountsConfirmation(); - - log('\nβš™οΈ Configuration Options (src/config.jsonc):'); - log(' - browser.headless: Set to true for background operation'); - log(' - execution.clusters: Number of parallel account processes'); - log(' - workers: Enable/disable specific tasks (dailySet, searches, etc.)'); - log(' - humanization: Add natural delays and behavior (recommended: enabled)'); - log(' - schedule: Configure automated daily runs'); - log(' - notifications: Discord webhooks, NTFY push alerts'); - log(' πŸ“š Full guide: docs/config.md\n'); - - const reviewConfig = (await prompt('Do you want to review config.jsonc now? (yes/no): ')).toLowerCase(); - if (['yes', 'y'].includes(reviewConfig)) { - log('⏸️ Setup paused. Please review src/config.jsonc, then re-run this setup.'); - log(' Common settings to check:'); - log(' - browser.headless (false = visible browser, true = background)'); - log(' - execution.runOnZeroPoints (false = skip when no points available)'); - log(' - humanization.enabled (true = natural behavior, recommended)'); - log(' - schedule.enabled (false = manual runs, true = automated scheduling)'); - log('\n After editing config.jsonc, run: npm run setup'); - process.exit(0); - } - - await ensureNpmAvailable(); - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install']); - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']); - await installPlaywrightBrowsers(); - - log('\nβœ… Setup complete!'); - log(' - Accounts configured: src/accounts.json'); - log(' - Configuration: src/config.jsonc'); - log(' - Documentation: docs/index.md\n'); - - const start = (await prompt('Do you want to start the automation now? (yes/no): ')).toLowerCase(); - if (['yes', 'y'].includes(start)) { - await runCommand(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'start']); - } else { - log('\nFinished setup. To start later, run: npm start'); - log('For automated scheduling, run: npm run start:schedule'); - } -} - -async function installPlaywrightBrowsers() { - const PLAYWRIGHT_MARKER = path.join(PROJECT_ROOT, '.playwright-chromium-installed'); - // Idempotent: skip if marker exists - if (fs.existsSync(PLAYWRIGHT_MARKER)) { - log('Playwright chromium already installed (marker found).'); - return; - } - log('Ensuring Playwright chromium browser is installed...'); - try { - await runCommand(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['playwright', 'install', 'chromium']); - fs.writeFileSync(PLAYWRIGHT_MARKER, new Date().toISOString()); - log('Playwright chromium install complete.'); - } catch (e) { - warn('Failed to install Playwright chromium automatically. You can manually run: npx playwright install chromium'); - } -} - -async function main() { - if (!fs.existsSync(SRC_DIR)) { - error('[ERROR] Cannot find src directory at ' + SRC_DIR); - process.exit(1); - } - process.chdir(PROJECT_ROOT); - - for (;;) { - log('============================'); - log(' Microsoft Rewards Setup '); - log('============================'); - log('Select an option:'); - log(' 1) Start program now (skip setup)'); - log(' 2) Full first-time setup'); - log(' 3) Exit'); - const choice = (await prompt('Enter choice (1/2/3): ')).trim(); - if (choice === '1') { await startOnly(); break; } - if (choice === '2') { await fullSetup(); break; } - if (choice === '3') { log('Exiting.'); process.exit(0); } - log('\nInvalid choice. Please select 1, 2 or 3.\n'); - } - // After completing action, optionally pause if launched by double click on Windows (no TTY detection simple heuristic) - if (process.platform === 'win32' && process.stdin.isTTY) { - log('\nDone. Press Enter to close.'); - await prompt(''); - } - process.exit(0); -} - -// Allow clean Ctrl+C -process.on('SIGINT', () => { console.log('\nInterrupted.'); process.exit(1); }); - -main().catch(err => { - error('\nSetup failed: ' + err.message); - process.exit(1); -}); diff --git a/setup/update/update.mjs b/setup/update/update.mjs deleted file mode 100644 index 305ccc5..0000000 --- a/setup/update/update.mjs +++ /dev/null @@ -1,225 +0,0 @@ -/* 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 - * - * Usage: - * node setup/update/update.mjs --git - * node setup/update/update.mjs --docker - */ - -import { spawn, execSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' - -function run(cmd, args, opts = {}) { - return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts }) - child.on('close', (code) => resolve(code ?? 0)) - child.on('error', () => resolve(1)) - }) -} - -async function which(cmd) { - const probe = process.platform === 'win32' ? 'where' : 'which' - const code = await run(probe, [cmd], { stdio: 'ignore' }) - return code === 0 -} - -function exec(cmd) { - try { - return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() - } catch { - return null - } -} - -async function updateGit() { - const hasGit = await which('git') - if (!hasGit) { - console.log('Git not found. Skipping update.') - return 1 - } - - console.log('\n' + '='.repeat(60)) - console.log('Smart Git Update') - console.log('='.repeat(60)) - - // Step 1: Read config to get user preferences - let userConfig = { autoUpdateConfig: false, autoUpdateAccounts: false } - try { - if (existsSync('src/config.jsonc')) { - const configContent = readFileSync('src/config.jsonc', 'utf8') - .replace(/\/\/.*$/gm, '') // remove comments - .replace(/\/\*[\s\S]*?\*\//g, '') // remove multi-line comments - const config = JSON.parse(configContent) - if (config.update) { - userConfig.autoUpdateConfig = config.update.autoUpdateConfig ?? false - userConfig.autoUpdateAccounts = config.update.autoUpdateAccounts ?? false - } - } - } catch (e) { - console.log('Warning: Could not read config.jsonc, using defaults (preserve local files)') - } - - console.log('\nUser preferences:') - console.log(` Auto-update config.jsonc: ${userConfig.autoUpdateConfig}`) - console.log(` Auto-update accounts.json: ${userConfig.autoUpdateAccounts}`) - - // Step 2: Fetch - console.log('\nFetching latest changes...') - await run('git', ['fetch', '--all', '--prune']) - - // Step 3: Get current branch - const currentBranch = exec('git branch --show-current') - if (!currentBranch) { - console.log('Could not determine current branch.') - return 1 - } - - // Step 4: Check which files changed in remote - const remoteBranch = `origin/${currentBranch}` - const filesChanged = exec(`git diff --name-only HEAD ${remoteBranch}`) - - if (!filesChanged) { - console.log('Already up to date!') - return 0 - } - - const changedFiles = filesChanged.split('\n').filter(f => f.trim()) - const configChanged = changedFiles.includes('src/config.jsonc') - const accountsChanged = changedFiles.includes('src/accounts.json') - - // Step 5: ALWAYS backup config and accounts (smart strategy!) - const backupDir = join(process.cwd(), '.update-backup') - mkdirSync(backupDir, { recursive: true }) - - const filesToRestore = [] - - if (existsSync('src/config.jsonc')) { - console.log('\nBacking up config.jsonc...') - writeFileSync(join(backupDir, 'config.jsonc'), readFileSync('src/config.jsonc', 'utf8')) - // Restore if: remote changed it AND user doesn't want auto-update - if (configChanged && !userConfig.autoUpdateConfig) { - filesToRestore.push('config.jsonc') - } - } - - if (existsSync('src/accounts.json')) { - console.log('Backing up accounts.json...') - writeFileSync(join(backupDir, 'accounts.json'), readFileSync('src/accounts.json', 'utf8')) - // Restore if: remote changed it AND user doesn't want auto-update - if (accountsChanged && !userConfig.autoUpdateAccounts) { - filesToRestore.push('accounts.json') - } - } - - // Show what will happen - console.log('\nRemote changes:') - if (configChanged) { - console.log(` config.jsonc: ${userConfig.autoUpdateConfig ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`) - } else { - console.log(' config.jsonc: no changes in remote') - } - if (accountsChanged) { - console.log(` accounts.json: ${userConfig.autoUpdateAccounts ? 'WILL UPDATE' : 'KEEPING LOCAL (restoring from backup)'}`) - } else { - console.log(' accounts.json: no changes in remote') - } - - // Step 6: Stash changes - const hasChanges = exec('git status --porcelain') - if (hasChanges) { - console.log('\nStashing local changes...') - await run('git', ['stash', 'push', '-m', 'Auto-update backup']) - } - - // Step 7: Pull - console.log('\nPulling latest code...') - const pullCode = await run('git', ['pull', '--rebase']) - - if (pullCode !== 0) { - console.log('Pull failed.') - if (hasChanges) await run('git', ['stash', 'pop']) - return pullCode - } - - // Step 8: Restore files based on user preferences - if (filesToRestore.length > 0) { - console.log('\nRestoring local files (per your config preferences)...') - for (const file of filesToRestore) { - const content = readFileSync(join(backupDir, file), 'utf8') - writeFileSync(join('src', file), content) - console.log(` βœ“ Restored ${file}`) - } - } - - // Step 9: Restore stash - if (hasChanges) { - await run('git', ['stash', 'pop']) - } - - // Step 9: Install & build - const hasNpm = await which('npm') - if (!hasNpm) return 0 - - console.log('\nInstalling dependencies...') - await run('npm', ['ci']) - - console.log('\nBuilding project...') - const buildCode = await run('npm', ['run', 'build']) - - console.log('\n' + '='.repeat(60)) - console.log('Update completed!') - console.log('='.repeat(60)) - - // Optional: Show community info for advanced features - if (Math.random() > 0.7) { - console.log('\nπŸ’‘ Tip: Looking for faster updates and advanced features?') - console.log(' Check out enhanced builds with better anti-detection.') - console.log(' Community: https://discord.gg/kn3695Kx32') - } - console.log('') - - return buildCode -} - -async function updateDocker() { - const hasDocker = await which('docker') - if (!hasDocker) return 1 - // Prefer compose v2 (docker compose) - await run('docker', ['compose', 'pull']) - return run('docker', ['compose', 'up', '-d']) -} - -async function main() { - const args = new Set(process.argv.slice(2)) - const doGit = args.has('--git') - const doDocker = args.has('--docker') - - let code = 0 - if (doGit) { - code = await updateGit() - } - 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) - } -} - -main().catch(() => { - // Only exit on error if not called from scheduler - if (process.env.FROM_SCHEDULER !== '1') { - process.exit(1) - } -}) diff --git a/src/accounts.example.json b/src/accounts.example.json new file mode 100644 index 0000000..2498cad --- /dev/null +++ b/src/accounts.example.json @@ -0,0 +1,32 @@ +{ + "accounts": [ + { + "enabled": true, + "email": "email_1@outlook.com", + "password": "password_1", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + "enabled": false, + "email": "email_2@outlook.com", + "password": "password_2", + "totp": "", + "recoveryEmail": "your_email@domain.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + } + ] +} \ No newline at end of file diff --git a/src/accounts.example.jsonc b/src/accounts.example.jsonc deleted file mode 100644 index d3edde1..0000000 --- a/src/accounts.example.jsonc +++ /dev/null @@ -1,151 +0,0 @@ -{ - // ============================================================ - // πŸ“§ MICROSOFT ACCOUNTS CONFIGURATION - // ============================================================ - - // ⚠️ IMPORTANT SECURITY NOTICE - // This file contains sensitive credentials. Never commit the real accounts.jsonc to version control. - // The .gitignore is configured to exclude accounts.jsonc but you should verify it's not tracked. - - // πŸ“Š MICROSOFT ACCOUNT LIMITS (Unofficial Guidelines) - // - New accounts per IP per day: ~3 (official soft limit) - // - Recommended active accounts per household IP: ~5 (to avoid suspicion) - // - Creating too many accounts quickly may trigger verification (phone, OTP, captcha) - // - Unusual activity can result in temporary blocks or account restrictions - - "accounts": [ - { - // ============================================================ - // πŸ‘€ ACCOUNT 1 - // ============================================================ - - // Enable or disable this account (true = active, false = skip) - "enabled": true, - - // Microsoft account email address - "email": "email_1@outlook.com", - - // Account password - "password": "password_1", - - // Two-Factor Authentication (2FA) TOTP secret (optional but HIGHLY recommended for security) - // Get this from your authenticator app (e.g., Microsoft Authenticator, Google Authenticator) - // Format: base32 secret key (e.g., "JBSWY3DPEHPK3PXP") - // Leave empty "" if 2FA is not enabled - "totp": "", - - // ⚠️ REQUIRED: Recovery email address associated with this Microsoft account - // During login, Microsoft shows the first 2 characters and the domain of the recovery email (e.g., "ab***@example.com") - // This field is MANDATORY to detect account compromise or bans: - // - The script compares what Microsoft displays with this configured recovery email - // - If they don't match, it alerts you that the account may be compromised or the recovery email was changed - // - This security check helps identify hijacked accounts before they cause issues - // Format: Full recovery email address (e.g., "backup@gmail.com") - "recoveryEmail": "your_email@domain.com", - - // ============================================================ - // 🌐 PROXY CONFIGURATION (Optional) - // ============================================================ - - "proxy": { - // Enable proxy for HTTP requests (axios/API calls) - // If false, proxy is only used for browser automation - "proxyAxios": true, - - // Proxy server URL (without protocol) - // Examples: "proxy.example.com", "123.45.67.89" - // Leave empty "" to disable proxy for this account - "url": "", - - // Proxy port number - "port": 0, - - // Proxy authentication username (leave empty if no auth required) - "username": "", - - // Proxy authentication password (leave empty if no auth required) - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 2 - // ============================================================ - - "enabled": false, - "email": "email_2@outlook.com", - "password": "password_2", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 3 - // ============================================================ - - "enabled": false, - "email": "email_3@outlook.com", - "password": "password_3", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 4 - // ============================================================ - - "enabled": false, - "email": "email_4@outlook.com", - "password": "password_4", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - }, - - { - // ============================================================ - // πŸ‘€ ACCOUNT 5 - // ============================================================ - - "enabled": false, - "email": "email_5@outlook.com", - "password": "password_5", - "totp": "", - "recoveryEmail": "your_email@domain.com", - - "proxy": { - "proxyAxios": true, - "url": "", - "port": 0, - "username": "", - "password": "" - } - } - ] -} diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index 166612a..9bdb890 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -25,23 +25,14 @@ class Browser { } async createBrowser(proxy: AccountProxy, email: string): Promise { - // Optional automatic browser installation (set AUTO_INSTALL_BROWSERS=1) - if (process.env.AUTO_INSTALL_BROWSERS === '1') { - try { - // Dynamically import child_process to avoid overhead otherwise - const { execSync } = await import('child_process') - execSync('npx playwright install chromium', { stdio: 'ignore' }) - } catch { /* silent */ } - } - - let browser: import('rebrowser-playwright').Browser + let browser: playwright.Browser try { // FORCE_HEADLESS env takes precedence (used in Docker with headless shell only) const envForceHeadless = process.env.FORCE_HEADLESS === '1' // Support legacy config.headless OR nested config.browser.headless const legacyHeadless = (this.bot.config as { headless?: boolean }).headless const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless - let headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false) + const headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false) const headless: boolean = Boolean(headlessValue) const engineName = 'chromium' // current hard-coded engine @@ -64,29 +55,29 @@ 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 "npm run pre-build" to install all dependencies', 'error') } else { this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error') } throw e } - // Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint - const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint - const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint - const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false } + // Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint + const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint + const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint + const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false } - const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint) + const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint) const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint() - const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint }) + const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint }) - // Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout) - const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout - const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout - const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000 - context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout)) + // Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout) + const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout + const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout + const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000 + context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout)) // Normalize viewport and page rendering so content fits typical screens try { diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index b7310dd..e2db4d3 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -25,80 +25,134 @@ export default class BrowserFunc { * @param {Page} page Playwright page */ async goHome(page: Page) { + const navigateHome = async () => { + try { + await page.goto(this.bot.config.baseURL, { + waitUntil: 'domcontentloaded', + timeout: 30000 + }) + } catch (e: any) { + if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) { + this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted, retrying...', 'warn') + await this.bot.utils.wait(1500) + await page.goto(this.bot.config.baseURL, { + waitUntil: 'domcontentloaded', + timeout: 30000 + }) + } else { + throw e + } + } + } try { const dashboardURL = new URL(this.bot.config.baseURL) - if (page.url() === dashboardURL.href) { - return + if (new URL(page.url()).hostname !== dashboardURL.hostname) { + await navigateHome() } - await page.goto(this.bot.config.baseURL) + let success = false for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) { await this.bot.utils.wait(TIMEOUTS.LONG) await this.bot.browser.utils.tryDismissAllMessages(page) try { - // If activities are found, exit the loop (SUCCESS - account is OK) await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 }) this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully') + success = true break + } catch { + const suspendedByHeader = await page + .waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }) + .then(() => true) + .catch(() => false) - } catch (error) { - // Activities not found yet - check if it's because account is suspended - // Only check suspension if we can't find activities (reduces false positives) - const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }).then(() => true).catch(() => false) - if (suspendedByHeader) { - this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error') + this.bot.log( + this.bot.isMobile, + 'GO-HOME', + `Account suspension detected by header selector (iteration ${iteration})`, + 'error' + ) throw new Error('Account has been suspended!') } - - // Secondary check: look for suspension text in main content area only + try { - const mainContent = (await page.locator('#contentContainer, #main, .main-content').first().textContent({ timeout: 500 }).catch(() => '')) || '' + const mainContent = + (await page + .locator('#contentContainer, #main, .main-content') + .first() + .textContent({ timeout: 500 }) + .catch(() => '')) || '' + const suspensionPatterns = [ /account\s+has\s+been\s+suspended/i, /suspended\s+due\s+to\s+unusual\s+activity/i, /your\s+account\s+is\s+temporarily\s+suspended/i ] - - const isSuspended = suspensionPatterns.some(pattern => pattern.test(mainContent)) + + const isSuspended = suspensionPatterns.some((p) => p.test(mainContent)) if (isSuspended) { - this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by content text (iteration ${iteration})`, 'error') + this.bot.log( + this.bot.isMobile, + 'GO-HOME', + `Account suspension detected by content text (iteration ${iteration})`, + 'error' + ) throw new Error('Account has been suspended!') } } catch (e) { - // Ignore errors in text check - not critical - this.bot.log(this.bot.isMobile, 'GO-HOME', `Suspension text check skipped: ${e}`, 'warn') + this.bot.log( + this.bot.isMobile, + 'GO-HOME', + `Suspension text check skipped: ${e instanceof Error ? e.message : String(e)}`, + 'warn' + ) + } + + const currentURL = new URL(page.url()) + if (currentURL.hostname !== dashboardURL.hostname) { + await this.bot.browser.utils.tryDismissAllMessages(page) + await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) + try { + await navigateHome() + } catch (e: any) { + if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) { + this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted again; continuing...', 'warn') + } else { + throw e + } + } + } else { + this.bot.log( + this.bot.isMobile, + 'GO-HOME', + `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, + 'warn' + ) } - - // Not suspended, just activities not loaded yet - continue to next iteration - this.bot.log(this.bot.isMobile, 'GO-HOME', `Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`, 'warn') } - // Below runs if the homepage was unable to be visited - const currentURL = new URL(page.url()) - - if (currentURL.hostname !== dashboardURL.hostname) { - await this.bot.browser.utils.tryDismissAllMessages(page) - - await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) - await page.goto(this.bot.config.baseURL) - } else { - this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully') - break - } - - await this.bot.utils.wait(TIMEOUTS.VERY_LONG) + const backoff = Math.min(TIMEOUTS.VERY_LONG, 1000 + iteration * 500) + await this.bot.utils.wait(backoff) } + if (!success) { + throw new Error('Failed to reach homepage or find activities within retry limit') + } } catch (error) { - throw this.bot.log(this.bot.isMobile, 'GO-HOME', 'An error occurred:' + error, 'error') + throw this.bot.log( + this.bot.isMobile, + 'GO-HOME', + 'An error occurred:' + (error instanceof Error ? ` ${error.message}` : ` ${String(error)}`), + 'error' + ) } } + /** * Fetch user dashboard data * @returns {DashboardData} Object of user bing rewards dashboard data @@ -114,7 +168,7 @@ export default class BrowserFunc { this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page') await this.goHome(target) } - let lastError: unknown = null + let lastError: unknown = null for (let attempt = 1; attempt <= 2; attempt++) { try { // Reload the page to get new data @@ -131,7 +185,7 @@ export default class BrowserFunc { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn') try { await this.goHome(target) - } catch {/* ignore */} + } catch {/* ignore */ } } else { break } @@ -143,7 +197,7 @@ export default class BrowserFunc { // Wait a bit longer for scripts to load, especially on mobile await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM) - + // Wait for the more-activities element to ensure page is fully loaded await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn') @@ -158,7 +212,7 @@ export default class BrowserFunc { if (!scriptContent) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn') - + // Force a navigation retry once before failing hard try { await this.goHome(target) @@ -169,20 +223,20 @@ export default class BrowserFunc { } catch (e) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn') } - + const retryContent = await target.evaluate(() => { const scripts = Array.from(document.querySelectorAll('script')) const targetScript = scripts.find(script => script.innerText.includes('var dashboard')) return targetScript?.innerText ? targetScript.innerText : null - }).catch(()=>null) - + }).catch(() => null) + if (!retryContent) { // Log additional debug info const scriptsDebug = await target.evaluate(() => { const scripts = Array.from(document.querySelectorAll('script')) return scripts.map(s => s.innerText.substring(0, 100)).join(' | ') }).catch(() => 'Unable to get script debug info') - + this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn') throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error') } @@ -422,10 +476,10 @@ export default class BrowserFunc { .map(el => $(el).text()) .filter(t => t.length > 0) .map(t => t.substring(0, 100)) - + this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error') this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn') - + throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error') } @@ -471,10 +525,10 @@ export default class BrowserFunc { const html = await page.content() const $ = load(html) - const element = $('.offer-cta').toArray().find((x: unknown) => { - const el = x as { attribs?: { href?: string } } - return !!el.attribs?.href?.includes(activity.offerId) - }) + const element = $('.offer-cta').toArray().find((x: unknown) => { + const el = x as { attribs?: { href?: string } } + return !!el.attribs?.href?.includes(activity.offerId) + }) if (element) { selector = `a[href*="${element.attribs.href}"]` } diff --git a/src/config.json b/src/config.json new file mode 100644 index 0000000..754b438 --- /dev/null +++ b/src/config.json @@ -0,0 +1,121 @@ +{ + "baseURL": "https://rewards.bing.com", + "sessionPath": "sessions", + "dryRun": false, + "browser": { + "headless": false, + "globalTimeout": "30s" + }, + "fingerprinting": { + "saveFingerprint": { + "mobile": true, + "desktop": true + } + }, + "execution": { + "parallel": false, + "runOnZeroPoints": false, + "clusters": 1 + }, + "jobState": { + "enabled": true, + "dir": "" + }, + "workers": { + "doDailySet": true, + "doMorePromotions": true, + "doPunchCards": true, + "doDesktopSearch": true, + "doMobileSearch": true, + "doDailyCheckIn": true, + "doReadToEarn": true, + "bundleDailySetWithSearch": true + }, + "search": { + "useLocalQueries": true, + "settings": { + "useGeoLocaleQueries": true, + "scrollRandomResults": true, + "clickRandomResults": true, + "retryMobileSearchAmount": 2, + "delay": { + "min": "2min", + "max": "5min" + } + } + }, + "queryDiversity": { + "enabled": true, + "sources": [ + "google-trends", + "reddit", + "local-fallback" + ], + "maxQueriesPerSource": 10, + "cacheMinutes": 30 + }, + "humanization": { + "enabled": true, + "stopOnBan": true, + "immediateBanAlert": true, + "actionDelay": { + "min": 500, + "max": 2200 + }, + "gestureMoveProb": 0.65, + "gestureScrollProb": 0.4, + "allowedWindows": [] + }, + "vacation": { + "enabled": true, + "minDays": 2, + "maxDays": 4 + }, + "riskManagement": { + "enabled": true, + "autoAdjustDelays": true, + "stopOnCritical": false, + "banPrediction": true, + "riskThreshold": 75 + }, + "retryPolicy": { + "maxAttempts": 3, + "baseDelay": 1000, + "maxDelay": "30s", + "multiplier": 2, + "jitter": 0.2 + }, + "proxy": { + "proxyGoogleTrends": true, + "proxyBingTerms": true + }, + "notifications": { + "webhook": { + "enabled": false, + "url": "" + }, + "conclusionWebhook": { + "enabled": false, + "url": "" + }, + "ntfy": { + "enabled": false, + "url": "", + "topic": "rewards", + "authToken": "" + } + }, + "logging": { + "excludeFunc": [ + "SEARCH-CLOSE-TABS", + "LOGIN-NO-PROMPT", + "FLOW" + ], + "webhookExcludeFunc": [ + "SEARCH-CLOSE-TABS", + "LOGIN-NO-PROMPT", + "FLOW" + ], + "redactEmails": true + } +} \ No newline at end of file diff --git a/src/config.jsonc b/src/config.jsonc deleted file mode 100644 index 663307e..0000000 --- a/src/config.jsonc +++ /dev/null @@ -1,241 +0,0 @@ -{ - // ============================================================ - // 🌐 GENERAL CONFIGURATION - // ============================================================ - - // Base URL for Microsoft Rewards dashboard (do not change unless necessary) - "baseURL": "https://rewards.bing.com", - - // Directory to store sessions (cookies, browser fingerprints) - "sessionPath": "sessions", - - // Dry-run mode: simulate execution without actually running tasks (useful for testing) - "dryRun": false, - - - // ============================================================ - // πŸ–₯️ BROWSER CONFIGURATION - // ============================================================ - - "browser": { - // false = visible window | true = headless mode (invisible) - "headless": false, - // Max timeout for operations (supports: 30000, "30s", "2min") - "globalTimeout": "30s" - }, - - "fingerprinting": { - // Persist browser fingerprints to improve consistency across runs - "saveFingerprint": { - "mobile": true, - "desktop": true - } - }, - - - // ============================================================ - // βš™οΈ EXECUTION & PERFORMANCE - // ============================================================ - - "execution": { - // true = Desktop + Mobile in parallel (faster, more resources) - // false = Sequential (slower, fewer resources) - "parallel": false, - // If false, skip execution when 0 points are available - "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 - }, - - - "jobState": { - // Save state to avoid duplicate work across restarts - "enabled": true, - // Custom state directory (empty = defaults to sessionPath/job-state) - "dir": "" - }, - - - // ============================================================ - // 🎯 TASKS & WORKERS - // ============================================================ - - "workers": { - // Select which tasks the bot should complete on desktop/mobile - "doDailySet": true, // Daily set tasks - "doMorePromotions": true, // More promotions section - "doPunchCards": true, // Punch cards - "doDesktopSearch": true, // Desktop searches - "doMobileSearch": true, // Mobile searches - "doDailyCheckIn": true, // Daily check-in - "doReadToEarn": true, // Read to earn - // If true, run desktop searches right after Daily Set - "bundleDailySetWithSearch": true - }, - - - // ============================================================ - // πŸ” SEARCH CONFIGURATION - // ============================================================ - - "search": { - // Use locale-specific query sources - "useLocalQueries": true, - "settings": { - // Use region-specific queries (at, fr, us, etc.) - "useGeoLocaleQueries": true, - // Randomly scroll search result pages (more natural behavior) - "scrollRandomResults": true, - // Occasionally click a result (safe targets only) - "clickRandomResults": true, - // Number of retries if mobile searches don't progress - "retryMobileSearchAmount": 2, - // Delay between searches - "delay": { - "min": "1min", - "max": "5min" - } - } - }, - - "queryDiversity": { - // Multi-source query generation: Reddit, News, Wikipedia instead of only Google Trends - "enabled": true, - // Available sources: google-trends, reddit, news, wikipedia, local-fallback - "sources": ["google-trends", "reddit", "local-fallback"], - // Max queries to fetch per source - "maxQueriesPerSource": 10, - // Cache duration in minutes (avoids hammering APIs) - "cacheMinutes": 30 - }, - - - // ============================================================ - // πŸ€– HUMANIZATION & NATURAL BEHAVIOR - // ============================================================ - - "humanization": { - // Human Mode: adds subtle micro-gestures & pauses to mimic real users - "enabled": true, - // If a ban is detected on any account, stop processing remaining accounts - "stopOnBan": true, - // Immediately send an alert (webhook/NTFY) when a ban is detected - "immediateBanAlert": true, - // Extra random pause between actions - "actionDelay": { - "min": 500, // 0.5 seconds minimum - "max": 2200 // 2.2 seconds maximum - }, - // Probability (0-1) to move mouse slightly between actions - "gestureMoveProb": 0.65, - // Probability (0-1) to perform a small scroll - "gestureScrollProb": 0.4, - // Optional execution time windows (e.g., ["08:30-11:00", "19:00-22:00"]) - // If specified, waits until inside a window before starting - "allowedWindows": [] - }, - - "vacation": { - // Monthly "vacation" block: skip a random range of days each month - // Each month, a random period between minDays and maxDays is selected - // and all runs within that date range are skipped (more human-like behavior) - "enabled": true, - "minDays": 2, - "maxDays": 4 - }, - - - // ============================================================ - // πŸ›‘οΈ RISK MANAGEMENT & SECURITY - // ============================================================ - - "riskManagement": { - // Dynamic delay adjustment based on detected risk signals - "enabled": true, - // Automatically increase delays when captchas/errors are detected - "autoAdjustDelays": true, - // Stop execution if risk level reaches critical threshold - "stopOnCritical": false, - // Enable ML-based ban prediction based on patterns - "banPrediction": true, - // Risk threshold (0-100). If exceeded, bot pauses or alerts you - "riskThreshold": 75 - }, - - "retryPolicy": { - // Generic retry/backoff for transient failures - "maxAttempts": 3, - "baseDelay": 1000, - "maxDelay": "30s", - "multiplier": 2, - "jitter": 0.2 - }, - - - // ============================================================ - // 🌐 PROXY - // ============================================================ - - "proxy": { - // Control which outbound calls go through your proxy - "proxyGoogleTrends": true, - "proxyBingTerms": true - }, - - - // ============================================================ - // πŸ”” NOTIFICATIONS - // ============================================================ - - "notifications": { - // 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" - }, - // 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" - }, - // NTFY push notifications (plain text) - "ntfy": { - "enabled": false, - "url": "", - "topic": "rewards", - "authToken": "" - } - }, - - - // ============================================================ - // πŸ“Š LOGGING & DIAGNOSTICS - // ============================================================ - - "logging": { - // Logging controls (see docs/config.md) - // Filter out noisy log buckets locally and for webhook summaries - "excludeFunc": [ - "SEARCH-CLOSE-TABS", - "LOGIN-NO-PROMPT", - "FLOW" - ], - "webhookExcludeFunc": [ - "SEARCH-CLOSE-TABS", - "LOGIN-NO-PROMPT", - "FLOW" - ], - // Email redaction toggle (true = secure, false = full emails) - "redactEmails": true - } -} - diff --git a/src/functions/Login.ts b/src/functions/Login.ts index f85276e..816e953 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -82,17 +82,17 @@ export class Login { clearInterval(this.compromisedInterval) this.compromisedInterval = undefined } - + this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process') this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined await page.goto('https://www.bing.com/rewards/dashboard') await this.disableFido(page) - await page.waitForLoadState('domcontentloaded').catch(()=>{}) + await page.waitForLoadState('domcontentloaded').catch(() => { }) await this.bot.browser.utils.reloadBadPage(page) await this.checkAccountLocked(page) - const already = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).then(()=>true).catch(()=>false) + const already = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).then(() => true).catch(() => false) if (!already) { await this.performLoginFlow(page, email, password) } else { @@ -145,7 +145,7 @@ export class Login { const req: AxiosRequestConfig = { url: this.tokenUrl, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: form.toString() } const resp = await this.bot.axios.request(req) const data: OAuth = resp.data - this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now()-start)/1000)}s`) + this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now() - start) / 1000)}s`) return data.access_token } @@ -157,7 +157,7 @@ export class Login { await this.bot.utils.wait(500) await this.tryRecoveryMismatchCheck(page, email) if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') { - this.bot.log(this.bot.isMobile,'LOGIN','Recovery mismatch detected – stopping before password entry','warn') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery mismatch detected – stopping before password entry', 'warn') return } // Try switching to password if a locale link is present (FR/EN) @@ -173,26 +173,26 @@ export class Login { // --------------- Input Steps --------------- private async inputEmail(page: Page, email: string) { - const field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(()=>null) + const field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(() => null) if (!field) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present', 'warn'); return } - const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(()=>null) + const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(() => null) if (!prefilled) { await page.fill(SELECTORS.emailInput, '') await page.fill(SELECTORS.emailInput, email) } else { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email prefilled') } - const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(()=>null) - if (next) { await next.click().catch(()=>{}); this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') } + const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null) + if (next) { await next.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') } } private async inputPasswordOr2FA(page: Page, password: string) { // Some flows require switching to password first - const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(()=>null) - if (switchBtn) { await switchBtn.click().catch(()=>{}); await this.bot.utils.wait(1000) } + const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(() => null) + if (switchBtn) { await switchBtn.click().catch(() => { }); await this.bot.utils.wait(1000) } // Rare flow: list of methods -> choose password - const passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(()=>null) + const passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(() => null) if (!passwordField) { const blocked = await this.detectSignInBlocked(page) if (blocked) return @@ -204,13 +204,13 @@ export class Login { const otherWaysHandled = await this.handleOtherWaysToSignIn(page) if (otherWaysHandled) { // Try to find password field again after clicking "Other ways" - const passwordFieldAfter = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(()=>null) + const passwordFieldAfter = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(() => null) if (passwordFieldAfter) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field found after "Other ways" flow') await page.fill(SELECTORS.passwordInput, '') await page.fill(SELECTORS.passwordInput, password) - const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(()=>null) - if (submit) { await submit.click().catch(()=>{}); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') } + const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null) + if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') } return } } @@ -226,8 +226,8 @@ export class Login { await page.fill(SELECTORS.passwordInput, '') await page.fill(SELECTORS.passwordInput, password) - const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(()=>null) - if (submit) { await submit.click().catch(()=>{}); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') } + const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null) + if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') } } @@ -247,7 +247,7 @@ export class Login { for (const selector of otherWaysSelectors) { const element = await page.waitForSelector(selector, { timeout: 1000 }).catch(() => null) if (element && await element.isVisible().catch(() => false)) { - await element.click().catch(() => {}) + await element.click().catch(() => { }) this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Other ways to sign in"') await this.bot.utils.wait(2000) // Wait for options to appear clicked = true @@ -273,7 +273,7 @@ export class Login { for (const selector of usePasswordSelectors) { const element = await page.waitForSelector(selector, { timeout: 1500 }).catch(() => null) if (element && await element.isVisible().catch(() => false)) { - await element.click().catch(() => {}) + await element.click().catch(() => { }) this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password"') await this.bot.utils.wait(2000) // Wait for password field to appear return true @@ -320,13 +320,13 @@ export class Login { if (this.bot.config.parallel) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Parallel mode: throttling authenticator push requests', 'log', 'yellow') for (let attempts = 0; attempts < 6; attempts++) { // max 6 minutes retry window - const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(()=>null) + const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(() => null) if (!resend) break await this.bot.utils.wait(60000) - await resend.click().catch(()=>{}) + await resend.click().catch(() => { }) } } - await page.click('button[aria-describedby="confirmSendTitle"]').catch(()=>{}) + await page.click('button[aria-describedby="confirmSendTitle"]').catch(() => { }) await this.bot.utils.wait(1500) try { const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2000 }) @@ -344,14 +344,14 @@ export class Login { return } catch { this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator code expired – refreshing') - const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(()=>null) - if (retryBtn) await retryBtn.click().catch(()=>{}) + const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(() => null) + if (retryBtn) await retryBtn.click().catch(() => { }) const refreshed = await this.fetchAuthenticatorNumber(page) if (!refreshed) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Could not refresh authenticator code', 'warn'); return } numberToPress = refreshed } } - this.bot.log(this.bot.isMobile,'LOGIN','Authenticator approval loop exited (max cycles reached)','warn') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval loop exited (max cycles reached)', 'warn') } private async handleSMSOrTotp(page: Page) { @@ -363,17 +363,17 @@ export class Login { await this.submitTotpCode(page, totpSelector) return } - } catch {/* ignore */} + } catch {/* ignore */ } } // Manual prompt with periodic page check this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - + // Monitor page changes while waiting for user input let userInput: string | null = null let checkInterval: NodeJS.Timeout | null = null - + try { const inputPromise = new Promise(res => { rl.question('Enter 2FA code:\n', ans => { @@ -395,11 +395,11 @@ export class Login { rl.close() userInput = 'skip' // Signal to skip submission } - } catch {/* ignore */} + } 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 @@ -411,7 +411,7 @@ export class Login { } finally { // Ensure cleanup happens even if errors occur if (checkInterval) clearInterval(checkInterval) - try { rl.close() } catch {/* ignore */} + try { rl.close() } catch {/* ignore */ } } } @@ -448,7 +448,7 @@ export class Login { try { const code = generateTOTP(this.currentTotpSecret!.trim()) const input = page.locator(selector).first() - if (!await input.isVisible().catch(()=>false)) { + if (!await input.isVisible().catch(() => false)) { this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn') return } @@ -457,9 +457,9 @@ export class Login { // Use unified selector system const submit = await this.findFirstVisibleLocator(page, Login.TOTP_SELECTORS.submit) if (submit) { - await submit.click().catch(()=>{}) + await submit.click().catch(() => { }) } else { - await page.keyboard.press('Enter').catch(()=>{}) + await page.keyboard.press('Enter').catch(() => { }) } this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically') } catch (error) { @@ -529,7 +529,7 @@ export class Login { for (const sel of selectors) { const loc = page.locator(sel).first() if (await loc.isVisible().catch(() => false)) { - await loc.click().catch(()=>{}) + await loc.click().catch(() => { }) return true } } @@ -560,7 +560,7 @@ export class Login { while (Date.now() - start < timeoutMs) { for (const sel of selectors) { const loc = page.locator(sel).first() - if (await loc.isVisible().catch(()=>false)) { + if (await loc.isVisible().catch(() => false)) { return sel } } @@ -588,7 +588,7 @@ export class Login { if (!portalSelector) { try { await this.bot.browser.func.goHome(page) - } catch {/* ignore fallback errors */} + } catch {/* ignore fallback errors */ } const fallbackSelector = await this.waitForRewardsRoot(page, 6000) if (!fallbackSelector) { @@ -605,60 +605,60 @@ export class Login { try { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing auth context') await page.goto('https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F') - for (let i=0;i<5;i++) { + for (let i = 0; i < 5; i++) { const u = new URL(page.url()) if (u.hostname === 'www.bing.com' && u.pathname === '/') { await this.bot.browser.utils.tryDismissAllMessages(page) - const ok = await page.waitForSelector('#id_n', { timeout: 3000 }).then(()=>true).catch(()=>false) - if (ok || this.bot.isMobile) { this.bot.log(this.bot.isMobile,'LOGIN-BING','Bing verification passed'); break } + const ok = await page.waitForSelector('#id_n', { timeout: 3000 }).then(() => true).catch(() => false) + if (ok || this.bot.isMobile) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification passed'); break } } await this.bot.utils.wait(1000) } } catch (e) { - this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification error: '+e, 'warn') + this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification error: ' + e, 'warn') } } private async checkAccountLocked(page: Page) { - const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(()=>true).catch(()=>false) - if (locked) throw this.bot.log(this.bot.isMobile,'CHECK-LOCKED','Account locked by Microsoft (serviceAbuseLandingTitle)','error') + const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(() => true).catch(() => false) + if (locked) throw this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'Account locked by Microsoft (serviceAbuseLandingTitle)', 'error') } // --------------- Passkey / Dialog Handling --------------- private async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') { let did = false // Video heuristic - const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(()=>null) + const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(() => null) if (biometric) { const btn = await page.$(SELECTORS.passkeySecondary) - if (btn) { await btn.click().catch(()=>{}); did = true; this.logPasskeyOnce('video heuristic') } + if (btn) { await btn.click().catch(() => { }); did = true; this.logPasskeyOnce('video heuristic') } } if (!did) { - const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(()=>null) - const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(()=>null) - const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(()=>null) + const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(() => null) + const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(() => null) + const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(() => null) const title = (titleEl ? (await titleEl.textContent()) : '')?.trim() || '' const looksLike = /sign in faster|passkey|fingerprint|face|pin/i.test(title) - if (looksLike && secBtn) { await secBtn.click().catch(()=>{}); did = true; this.logPasskeyOnce('title heuristic '+title) } + if (looksLike && secBtn) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('title heuristic ' + title) } else if (!did && secBtn && primBtn) { - const text = (await secBtn.textContent()||'').trim() - if (/skip for now/i.test(text)) { await secBtn.click().catch(()=>{}); did = true; this.logPasskeyOnce('secondary button text') } + const text = (await secBtn.textContent() || '').trim() + if (/skip for now/i.test(text)) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('secondary button text') } } if (!did) { const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now")]').first() - if (await textBtn.isVisible().catch(()=>false)) { await textBtn.click().catch(()=>{}); did = true; this.logPasskeyOnce('text fallback') } + if (await textBtn.isVisible().catch(() => false)) { await textBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('text fallback') } } if (!did) { const close = await page.$('#close-button') - if (close) { await close.click().catch(()=>{}); did = true; this.logPasskeyOnce('close button') } + if (close) { await close.click().catch(() => { }); did = true; this.logPasskeyOnce('close button') } } } // KMSI prompt - const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(()=>null) + const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null) if (kmsi) { const yes = await page.$(SELECTORS.passkeyPrimary) - if (yes) { await yes.click().catch(()=>{}); did = true; this.bot.log(this.bot.isMobile,'LOGIN-KMSI','Accepted KMSI prompt') } + if (yes) { await yes.click().catch(() => { }); did = true; this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt') } } if (!did && context === 'main') { @@ -666,7 +666,7 @@ export class Login { const now = Date.now() if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) { this.lastNoPromptLog = now - this.bot.log(this.bot.isMobile,'LOGIN-NO-PROMPT',`No dialogs (x${this.noPromptIterations})`) + this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`) if (this.noPromptIterations > 50) this.noPromptIterations = 0 } } else if (did) { @@ -677,7 +677,7 @@ export class Login { private logPasskeyOnce(reason: string) { if (this.passkeyHandled) return this.passkeyHandled = true - this.bot.log(this.bot.isMobile,'LOGIN-PASSKEY',`Dismissed passkey prompt (${reason})`) + this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`) } // --------------- Security Detection --------------- @@ -685,11 +685,11 @@ export class Login { if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') return true try { let text = '' - for (const sel of ['[data-testid="title"]','h1','div[role="heading"]','div.text-title']) { - const el = await page.waitForSelector(sel, { timeout: 600 }).catch(()=>null) + for (const sel of ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']) { + const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null) if (el) { - const t = (await el.textContent()||'').trim() - if (t && t.length < 300) text += ' '+t + const t = (await el.textContent() || '').trim() + if (t && t.length < 300) text += ' ' + t } } const lower = text.toLowerCase() @@ -697,57 +697,53 @@ export class Login { for (const p of SIGN_IN_BLOCK_PATTERNS) { if (p.re.test(lower)) { matched = p.label; break } } if (!matched) return false const email = this.bot.currentAccountEmail || 'unknown' - const docsUrl = this.getDocsUrl('we-cant-sign-you-in') const incident: SecurityIncident = { kind: 'We can\'t sign you in (blocked)', account: email, details: [matched ? `Pattern: ${matched}` : 'Pattern: unknown'], - next: ['Manual recovery required before continuing'], - docsUrl + next: ['Manual recovery required before continuing'] } - await this.sendIncidentAlert(incident,'warn') + await this.sendIncidentAlert(incident, 'warn') this.bot.compromisedModeActive = true this.bot.compromisedReason = 'sign-in-blocked' this.startCompromisedInterval() - await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(()=>{}) - // Open security docs for immediate guidance (best-effort) - await this.openDocsTab(page, docsUrl).catch(()=>{}) + await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(() => { }) return true } catch { return false } } - private async tryRecoveryMismatchCheck(page: Page, email: string) { try { await this.detectAndHandleRecoveryMismatch(page, email) } catch {/* ignore */} } + private async tryRecoveryMismatchCheck(page: Page, email: string) { try { await this.detectAndHandleRecoveryMismatch(page, email) } catch {/* ignore */ } } private async detectAndHandleRecoveryMismatch(page: Page, email: string) { try { const recoveryEmail: string | undefined = this.bot.currentAccountRecoveryEmail if (!recoveryEmail || !/@/.test(recoveryEmail)) return const accountEmail = email - const parseRef = (val: string) => { const [l,d] = val.split('@'); return { local: l||'', domain:(d||'').toLowerCase(), prefix2:(l||'').slice(0,2).toLowerCase() } } - const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r=>r.domain && r.prefix2) + const parseRef = (val: string) => { const [l, d] = val.split('@'); return { local: l || '', domain: (d || '').toLowerCase(), prefix2: (l || '').slice(0, 2).toLowerCase() } } + const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r => r.domain && r.prefix2) if (refs.length === 0) return const candidates: string[] = [] // Direct selectors (Microsoft variants + French spans) const sel = '[data-testid="recoveryEmailHint"], #recoveryEmail, [id*="ProofEmail"], [id*="EmailProof"], [data-testid*="Email"], span:has(span.fui-Text)' - const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(()=>null) - if (el) { const t = (await el.textContent()||'').trim(); if (t) candidates.push(t) } + const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(() => null) + if (el) { const t = (await el.textContent() || '').trim(); if (t) candidates.push(t) } // List items const li = page.locator('[role="listitem"], li') - const liCount = await li.count().catch(()=>0) - for (let i=0;i''))?.trim()||''; if (t && /@/.test(t)) candidates.push(t) } + const liCount = await li.count().catch(() => 0) + for (let i = 0; i < liCount && i < 12; i++) { const t = (await li.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && /@/.test(t)) candidates.push(t) } // XPath generic masked patterns const xp = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "β€’"))]') - const xpCount = await xp.count().catch(()=>0) - for (let i=0;i''))?.trim()||''; if (t && t.length<300) candidates.push(t) } + const xpCount = await xp.count().catch(() => 0) + for (let i = 0; i < xpCount && i < 12; i++) { const t = (await xp.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && t.length < 300) candidates.push(t) } // Normalize const seen = new Set() - const norm = (s:string)=>s.replace(/\s+/g,' ').trim() - const uniq = candidates.map(norm).filter(t=>t && !seen.has(t) && seen.add(t)) + const norm = (s: string) => s.replace(/\s+/g, ' ').trim() + const uniq = candidates.map(norm).filter(t => t && !seen.has(t) && seen.add(t)) // Masked filter - let masked = uniq.filter(t=>/@/.test(t) && /[*β€’]/.test(t)) + let masked = uniq.filter(t => /@/.test(t) && /[*β€’]/.test(t)) if (masked.length === 0) { // Fallback full HTML scan @@ -758,14 +754,14 @@ export class Login { const found = new Set() let m: RegExpExecArray | null while ((m = generic.exec(html)) !== null) found.add(m[0]) - while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g,'').trim(); if (raw) found.add(raw) } + while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) } if (found.size > 0) masked = Array.from(found) - } catch {/* ignore */} + } catch {/* ignore */ } } if (masked.length === 0) return // Prefer one mentioning email/adresse - const preferred = masked.find(t=>/email|courriel|adresse|mail/i.test(t)) || masked[0]! + const preferred = masked.find(t => /email|courriel|adresse|mail/i.test(t)) || masked[0]! // Extract the masked email: Microsoft sometimes shows only first 1 char (k*****@domain) or 2 chars (ko*****@domain). // We ONLY compare (1 or 2) leading visible alphanumeric chars + full domain (case-insensitive). // This avoids false positives when the displayed mask hides the 2nd char. @@ -776,15 +772,15 @@ export class Login { const use = m || loose const extracted = use ? use[0] : preferred const extractedLower = extracted.toLowerCase() - let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase() - let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase() + let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase() + let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase() if (!observedDomain && extractedLower.includes('@')) { const parts = extractedLower.split('@') observedDomain = parts[1] || '' } if (!observedPrefix && extractedLower.includes('@')) { const parts = extractedLower.split('@') - observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi,'').slice(0,2) + observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi, '').slice(0, 2) } // Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic @@ -798,55 +794,52 @@ export class Login { }) if (!matchRef) { - const docsUrl = this.getDocsUrl('recovery-email-mismatch') const incident: SecurityIncident = { - kind:'Recovery email mismatch', + kind: 'Recovery email mismatch', account: email, - details:[ + details: [ `MaskedShown: ${preferred}`, `Extracted: ${extracted}`, `Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`, - `Expected => ${refs.map(r=>`${r.prefix2}**@${r.domain}`).join(' OR ')}` + `Expected => ${refs.map(r => `${r.prefix2}**@${r.domain}`).join(' OR ')}` ], - next:[ + next: [ 'Automation halted globally (standby engaged).', 'Verify account security & recovery email in Microsoft settings.', 'Update accounts.json if the change was legitimate before restart.' - ], - docsUrl + ] } - await this.sendIncidentAlert(incident,'critical') + await this.sendIncidentAlert(incident, 'critical') this.bot.compromisedModeActive = true this.bot.compromisedReason = 'recovery-mismatch' this.startCompromisedInterval() - await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(()=>{}) - await this.openDocsTab(page, docsUrl).catch(()=>{}) + await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(() => { }) } else { const mode = observedPrefix.length === 1 ? 'lenient' : 'strict' - this.bot.log(this.bot.isMobile,'LOGIN-RECOVERY',`Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`) + this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`) } - } catch {/* non-fatal */} + } catch {/* non-fatal */ } } private async switchToPasswordLink(page: Page) { try { const link = await page.locator('xpath=//span[@role="button" and (contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"use your password") or contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"utilisez votre mot de passe"))]').first() - if (await link.isVisible().catch(()=>false)) { - await link.click().catch(()=>{}) + if (await link.isVisible().catch(() => false)) { + await link.click().catch(() => { }) await this.bot.utils.wait(800) - this.bot.log(this.bot.isMobile,'LOGIN','Clicked "Use your password" link') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password" link') } - } catch {/* ignore */} + } catch {/* ignore */ } } // --------------- Incident Helpers --------------- - private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn'|'critical'='warn') { - const lines = [ `[Incident] ${incident.kind}`, `Account: ${incident.account}` ] + private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn' | 'critical' = 'warn') { + const lines = [`[Incident] ${incident.kind}`, `Account: ${incident.account}`] if (incident.details?.length) lines.push(`Details: ${incident.details.join(' | ')}`) if (incident.next?.length) lines.push(`Next: ${incident.next.join(' -> ')}`) if (incident.docsUrl) lines.push(`Docs: ${incident.docsUrl}`) - const level: 'warn'|'error' = severity === 'critical' ? 'error' : 'warn' - this.bot.log(this.bot.isMobile,'SECURITY',lines.join(' | '), level) + const level: 'warn' | 'error' = severity === 'critical' ? 'error' : 'warn' + this.bot.log(this.bot.isMobile, 'SECURITY', lines.join(' | '), level) try { const { ConclusionWebhook } = await import('../util/ConclusionWebhook') const fields = [ @@ -858,36 +851,18 @@ export class Login { await ConclusionWebhook( this.bot.config, `πŸ” ${incident.kind}`, - '_Security check by @Light_', + '_Security check', fields, severity === 'critical' ? 0xFF0000 : 0xFFAA00 ) - } catch {/* ignore */} - } - - private getDocsUrl(anchor?: string) { - const base = process.env.DOCS_BASE?.trim() || 'https://github.com/LightZirconite/Microsoft-Rewards-Script-Private/blob/v2/docs/security.md' - const map: Record = { - 'recovery-email-mismatch':'#recovery-email-mismatch', - 'we-cant-sign-you-in':'#we-cant-sign-you-in-blocked' - } - return anchor && map[anchor] ? `${base}${map[anchor]}` : base + } catch {/* ignore */ } } private startCompromisedInterval() { if (this.compromisedInterval) clearInterval(this.compromisedInterval) - this.compromisedInterval = setInterval(()=>{ - try { this.bot.log(this.bot.isMobile,'SECURITY','Account in security standby. Review before proceeding. Security check by @Light','warn') } catch {/* ignore */} - }, 5*60*1000) - } - - - private async openDocsTab(page: Page, url: string) { - try { - const ctx = page.context() - const tab = await ctx.newPage() - await tab.goto(url, { waitUntil: 'domcontentloaded' }) - } catch {/* ignore */} + this.compromisedInterval = setInterval(() => { + try { this.bot.log(this.bot.isMobile, 'SECURITY', 'Account in security standby. Review before proceeding.', 'warn') } catch {/* ignore */ } + }, 5 * 60 * 1000) } // --------------- Infrastructure --------------- @@ -898,6 +873,6 @@ export class Login { body.isFidoSupported = false route.continue({ postData: JSON.stringify(body) }) } catch { route.continue() } - }).catch(()=>{}) + }).catch(() => { }) } } diff --git a/src/functions/activities/DailyCheckIn.ts b/src/functions/activities/DailyCheckIn.ts index 23d821a..97f7d8c 100644 --- a/src/functions/activities/DailyCheckIn.ts +++ b/src/functions/activities/DailyCheckIn.ts @@ -31,7 +31,8 @@ export class DailyCheckIn extends Workers { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'X-Rewards-Country': geoLocale, - 'X-Rewards-Language': 'en' + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' }, data: JSON.stringify(jsonData) } diff --git a/src/functions/activities/ReadToEarn.ts b/src/functions/activities/ReadToEarn.ts index 65c8862..04c18f7 100644 --- a/src/functions/activities/ReadToEarn.ts +++ b/src/functions/activities/ReadToEarn.ts @@ -47,7 +47,8 @@ export class ReadToEarn extends Workers { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'X-Rewards-Country': geoLocale, - 'X-Rewards-Language': 'en' + 'X-Rewards-Language': 'en', + 'X-Rewards-ismobile': 'true' }, data: JSON.stringify(jsonData) } diff --git a/src/index.ts b/src/index.ts index a54d79a..deae327 100644 --- a/src/index.ts +++ b/src/index.ts @@ -378,7 +378,7 @@ 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') + 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.','warn','yellow') const standbyInterval = setInterval(() => { log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow') }, 5 * 60 * 1000) @@ -467,13 +467,13 @@ export class MicrosoftRewardsBot { if (this.compromisedModeActive) { // User wants the page to remain open for manual recovery. Do not proceed to tasks. const reason = this.compromisedReason || 'security-issue' - log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') + log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}.`, '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_`, + `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks`, undefined, 0xFFAA00 ) @@ -571,13 +571,13 @@ export class MicrosoftRewardsBot { await this.login.login(this.homePage, account.email, account.password, account.totp) if (this.compromisedModeActive) { const reason = this.compromisedReason || 'security-issue' - log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}. Security check by @Light`, 'warn', 'yellow') + log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}.`, '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_`, + `**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks`, undefined, 0xFFAA00 ) @@ -790,7 +790,7 @@ export class MicrosoftRewardsBot { 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_`, + `@everyone\n\n**Account:** ${email}\n**Reason:** ${reason}\n**Action:** Pausing all further accounts. We will not proceed until this is resolved.`, undefined, DISCORD.COLOR_RED ) diff --git a/src/interface/Config.ts b/src/interface/Config.ts index ac3e419..e90f8ae 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -22,7 +22,6 @@ export interface Config { webhook: ConfigWebhook; conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary ntfy: ConfigNtfy; - passesPerRun?: number; vacation?: ConfigVacation; // Optional monthly contiguous off-days crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts index 12e8f7b..395d2e8 100644 --- a/src/util/ConclusionWebhook.ts +++ b/src/util/ConclusionWebhook.ts @@ -29,12 +29,6 @@ interface DiscordEmbed { } } -interface WebhookPayload { - username: string - avatar_url: string - embeds: DiscordEmbed[] -} - interface AccountSummary { email: string totalCollected: number @@ -89,26 +83,20 @@ export async function ConclusionWebhook( embed.fields = fields } - // Use custom webhook settings if provided, otherwise fall back to defaults - const webhookUsername = config.webhook?.username || config.conclusionWebhook?.username || 'Microsoft Rewards' - const webhookAvatarUrl = config.webhook?.avatarUrl || config.conclusionWebhook?.avatarUrl || DISCORD.AVATAR_URL - - const payload: WebhookPayload = { - username: webhookUsername, - avatar_url: webhookAvatarUrl, - embeds: [embed] - } - const postWebhook = async (url: string, label: string) => { const maxAttempts = 3 let lastError: unknown = null for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - await axios.post(url, payload, { - headers: { 'Content-Type': 'application/json' }, - timeout: 15000 - }) + await axios.post(url, + { + embeds: [embed] + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 15000 + }) log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`) return } catch (error) { @@ -160,7 +148,7 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion 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` @@ -199,14 +187,14 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion // Build global statistics field const globalStats = [ - `**πŸ’Ž Total Points Earned**`, + '**πŸ’Ž Total Points Earned**', `\`${data.totalInitial.toLocaleString()}\` β†’ \`${data.totalEnd.toLocaleString()}\` **(+${data.totalCollected.toLocaleString()})**`, '', - `**πŸ“Š Accounts Processed**`, + '**πŸ“Š Accounts Processed**', `βœ… Success: **${data.successes}** | ⚠️ Errors: **${data.accountsWithErrors}** | 🚫 Banned: **${data.accountsBanned}**`, `Total: **${data.totalAccounts}** ${data.totalAccounts === 1 ? 'account' : 'accounts'}`, '', - `**⚑ Performance**`, + '**⚑ Performance**', `Average: **${data.avgPointsPerAccount}pts/account** in **${formatDuration(data.avgDuration)}**`, `Total Runtime: **${formatDuration(data.totalDuration)}**` ].join('\n') @@ -215,33 +203,33 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion 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 + const fieldName = accountChunks.length > 1 ? `πŸ“ˆ Account Details (${chunkIndex + 1}/${accountChunks.length})` : 'πŸ“ˆ Account Details' @@ -295,15 +283,6 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion }) } - // 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 @@ -311,7 +290,9 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - await axios.post(url, payload, { + await axios.post(url, { + embeds: embeds + }, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 }) @@ -339,13 +320,13 @@ export async function ConclusionWebhookEnhanced(config: Config, data: Conclusion // Optional NTFY notification (simplified summary) if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) { const message = [ - `🎯 Microsoft Rewards Summary`, + '🎯 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 { diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts index fc97fa2..31e00e1 100644 --- a/src/util/ConfigValidator.ts +++ b/src/util/ConfigValidator.ts @@ -15,7 +15,7 @@ export interface ValidationResult { } /** - * ConfigValidator performs intelligent validation of config.jsonc and accounts.json + * ConfigValidator performs intelligent validation of config.json and accounts.json * before execution to catch common mistakes, conflicts, and security issues. */ export class ConfigValidator { @@ -400,7 +400,6 @@ export class ConfigValidator { const configRaw = fs.readFileSync(configPath, 'utf-8') const accountsRaw = fs.readFileSync(accountsPath, 'utf-8') - // Remove JSONC comments (basic approach) const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') const config: Config = JSON.parse(configJson) const accounts: Account[] = JSON.parse(accountsRaw) diff --git a/src/util/Load.ts b/src/util/Load.ts index 4296d3f..47e3598 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -10,62 +10,6 @@ import { Config, ConfigSaveFingerprint } from '../interface/Config' let configCache: Config let configSourcePath = '' -// Basic JSON comment stripper (supports // line and /* block */ comments while preserving strings) -function stripJsonComments(input: string): string { - let out = '' - let inString = false - let stringChar = '' - let inLine = false - let inBlock = false - for (let i = 0; i < input.length; i++) { - const ch = input[i]! - const next = input[i + 1] - if (inLine) { - if (ch === '\n' || ch === '\r') { - inLine = false - out += ch - } - continue - } - if (inBlock) { - if (ch === '*' && next === '/') { - inBlock = false - i++ - } - continue - } - if (inString) { - out += ch - if (ch === '\\') { // escape next char - i++ - if (i < input.length) out += input[i] - continue - } - if (ch === stringChar) { - inString = false - } - continue - } - if (ch === '"' || ch === '\'') { - inString = true - stringChar = ch - out += ch - continue - } - if (ch === '/' && next === '/') { - inLine = true - i++ - continue - } - if (ch === '/' && next === '*') { - inBlock = true - i++ - continue - } - out += ch - } - return out -} // Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface function normalizeConfig(raw: unknown): Config { @@ -79,7 +23,6 @@ function normalizeConfig(raw: unknown): Config { const parallel = n.execution?.parallel ?? n.parallel ?? false const runOnZeroPoints = n.execution?.runOnZeroPoints ?? n.runOnZeroPoints ?? false const clusters = n.execution?.clusters ?? n.clusters ?? 1 - const passesPerRun = n.execution?.passesPerRun ?? n.passesPerRun // Search const useLocalQueries = n.search?.useLocalQueries ?? n.searchOnBingLocalQueries ?? false @@ -166,17 +109,16 @@ function normalizeConfig(raw: unknown): Config { searchOnBingLocalQueries: !!useLocalQueries, globalTimeout, searchSettings, - humanization: n.humanization, + humanization: n.humanization, retryPolicy: n.retryPolicy, jobState: n.jobState, logExcludeFunc, webhookLogExcludeFunc, - logging, // retain full logging object for live webhook usage - proxy: n.proxy ?? { proxyGoogleTrends: true, proxyBingTerms: true }, + logging, // retain full logging object for live webhook usage + proxy: n.proxy ?? { proxyGoogleTrends: true, proxyBingTerms: true }, webhook, conclusionWebhook, ntfy, - passesPerRun: passesPerRun, vacation: n.vacation, crashRecovery: n.crashRecovery || {} } @@ -196,41 +138,35 @@ export function loadAccounts(): Account[] { const envJson = process.env.ACCOUNTS_JSON const envFile = process.env.ACCOUNTS_FILE - let raw: string | undefined + let json: string | undefined if (envJson && envJson.trim().startsWith('[')) { - raw = envJson + json = envJson } else if (envFile && envFile.trim()) { const full = path.isAbsolute(envFile) ? envFile : path.join(process.cwd(), envFile) if (!fs.existsSync(full)) { throw new Error(`ACCOUNTS_FILE not found: ${full}`) } - raw = fs.readFileSync(full, 'utf-8') + json = fs.readFileSync(full, 'utf-8') } else { // Try multiple locations to support both root mounts and dist mounts - // Support both .json and .jsonc extensions + // Support both .json and .json 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), + path.join(__dirname, '../src', file), + path.join(process.cwd(), file), + path.join(process.cwd(), 'src', file), + path.join(__dirname, file) ] let chosen: string | null = null for (const p of candidates) { try { if (fs.existsSync(p)) { chosen = p; break } } catch { /* ignore */ } } if (!chosen) throw new Error(`accounts file not found in: ${candidates.join(' | ')}`) - raw = fs.readFileSync(chosen, 'utf-8') + json = fs.readFileSync(chosen, 'utf-8') } // Support comments in accounts file (same as config) - const cleaned = stripJsonComments(raw) - const parsedUnknown = JSON.parse(cleaned) + const parsedUnknown = JSON.parse(json) // Accept either a root array or an object with an `accounts` array, ignore `_note` const parsed = Array.isArray(parsedUnknown) ? parsedUnknown : (parsedUnknown && typeof parsedUnknown === 'object' && Array.isArray((parsedUnknown as { accounts?: unknown }).accounts) ? (parsedUnknown as { accounts: unknown[] }).accounts : null) if (!Array.isArray(parsed)) throw new Error('accounts must be an array') @@ -257,8 +193,8 @@ export function loadConfig(): Config { return configCache } - // Resolve configuration file from common locations (supports .jsonc and .json) - const names = ['config.jsonc', 'config.json'] + // Resolve configuration file from common locations + const names = ['config.json'] const bases = [ path.join(__dirname, '../'), // dist root when compiled path.join(__dirname, '../src'), // fallback: running dist but config still in src @@ -272,19 +208,19 @@ export function loadConfig(): Config { candidates.push(path.join(base, name)) } } - let cfgPath: string | null = null + let cfgPath: string | null = null for (const p of candidates) { try { if (fs.existsSync(p)) { cfgPath = p; break } } catch { /* ignore */ } } if (!cfgPath) throw new Error(`config.json not found in: ${candidates.join(' | ')}`) const config = fs.readFileSync(cfgPath, 'utf-8') - const text = config.replace(/^\uFEFF/, '') - const raw = JSON.parse(stripJsonComments(text)) - const normalized = normalizeConfig(raw) - configCache = normalized // Set as cache - configSourcePath = cfgPath + const json = config.replace(/^\uFEFF/, '') + const raw = JSON.parse(json) + const normalized = normalizeConfig(raw) + configCache = normalized // Set as cache + configSourcePath = cfgPath - return normalized + return normalized } catch (error) { throw new Error(error as string) } @@ -357,12 +293,12 @@ export async function saveFingerprintData(sessionPath: string, email: string, is await fs.promises.mkdir(sessionDir, { recursive: true }) } - // Save fingerprint to files (write both legacy and corrected names for compatibility) - const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`) - const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`) - const payload = JSON.stringify(fingerprint) - await fs.promises.writeFile(correct, payload) - try { await fs.promises.writeFile(legacy, payload) } catch { /* ignore */ } + // Save fingerprint to files (write both legacy and corrected names for compatibility) + const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`) + const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`) + const payload = JSON.stringify(fingerprint) + await fs.promises.writeFile(correct, payload) + try { await fs.promises.writeFile(legacy, payload) } catch { /* ignore */ } return sessionDir } catch (error) { diff --git a/src/util/Logger.ts b/src/util/Logger.ts index a0a00f1..f58b5db 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -5,8 +5,6 @@ import { Ntfy } from './Ntfy' import { loadConfig } from './Load' import { DISCORD } from '../constants' -const DEFAULT_LIVE_LOG_USERNAME = 'MS Rewards - Live Logs' - type WebhookBuffer = { lines: string[] sending: boolean @@ -19,7 +17,7 @@ const webhookBuffers = new Map() 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 @@ -44,12 +42,7 @@ function getBuffer(url: string): WebhookBuffer { 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 +62,6 @@ async function sendBatch(url: string, buf: WebhookBuffer) { // Enhanced webhook payload with embed, username and avatar const payload = { - username: webhookUsername, - avatar_url: webhookAvatarUrl, embeds: [{ description: `\`\`\`\n${content}\n\`\`\``, color: determineColorFromContent(content), @@ -143,13 +134,13 @@ export function log(isMobile: boolean | 'main', title: string, message: string, const currentTime = new Date().toLocaleString() const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP' - + // Clean string for notifications (no chalk, structured) type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean } const loggingCfg: LoggingCfg = (configAny.logging || {}) as LoggingCfg const shouldRedact = !!loggingCfg.redactEmails const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => { - const [u, d] = m.split('@'); return `${(u||'').slice(0,2)}***@${d||''}` + const [u, d] = m.split('@'); return `${(u || '').slice(0, 2)}***@${d || ''}` }) : s const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`) @@ -160,7 +151,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, message.toLowerCase().includes('press the number'), message.toLowerCase().includes('no points to earn') ], - error: [], + error: [], warn: [ message.toLowerCase().includes('aborting'), message.toLowerCase().includes('didn\'t gain') @@ -179,11 +170,11 @@ export function log(isMobile: boolean | 'main', title: string, message: string, const typeIndicator = type === 'error' ? 'βœ—' : type === 'warn' ? '⚠' : 'βœ“' const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green - + // Add contextual icon based on title/message (ASCII-safe for Windows PowerShell) const titleLower = title.toLowerCase() const msgLower = message.toLowerCase() - + // ASCII-safe icons for Windows PowerShell compatibility const iconMap: Array<[RegExp, string]> = [ [/security|compromised/i, '[SECURITY]'], @@ -198,7 +189,7 @@ export function log(isMobile: boolean | 'main', title: string, message: string, [/browser/i, '[BROWSER]'], [/main/i, '[MAIN]'] ] - + let icon = '' for (const [pattern, symbol] of iconMap) { if (pattern.test(titleLower) || pattern.test(msgLower)) { @@ -206,9 +197,9 @@ export function log(isMobile: boolean | 'main', title: string, message: string, break } } - + const iconPart = icon ? icon + ' ' : '' - + const formattedStr = [ chalk.gray(`[${currentTime}]`), chalk.gray(`[${process.pid}]`),