diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..5f94c9a
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,3 @@
+dist/
+node_modules/
+setup/
diff --git a/.eslintrc.js b/.eslintrc.js
index b221152..f38d24b 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -16,10 +16,7 @@ module.exports = {
'@typescript-eslint'
],
'rules': {
- 'linebreak-style': [
- 'error',
- 'unix'
- ],
+ 'linebreak-style': 'off',
'quotes': [
'error',
'single'
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..dd4ccf9
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,28 @@
+{
+ "root": true,
+ "env": {
+ "es2021": true,
+ "node": true
+ },
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "project": ["./tsconfig.json"],
+ "sourceType": "module",
+ "ecmaVersion": 2021
+ },
+ "plugins": ["@typescript-eslint", "modules-newline"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "rules": {
+ "modules-newline/import-declaration-newline": ["warn", { "count": 3 }],
+ "@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports" }],
+ "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
+ "@typescript-eslint/no-explicit-any": "off",
+ "no-console": ["warn", { "allow": ["error", "warn"] }],
+ "quotes": ["error", "double", { "avoidEscape": true }],
+ "linebreak-style": "off"
+ },
+ "ignorePatterns": ["dist/**", "node_modules/**", "setup/**"]
+}
diff --git a/.gitignore b/.gitignore
index 6d623a7..f3985a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,11 @@
sessions/
dist/
node_modules/
+.github/
package-lock.json
accounts.json
notes
accounts.dev.json
accounts.main.json
-.DS_Store
\ No newline at end of file
+.DS_Store
+.playwright-chromium-installed
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index c92d736..970db3e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
###############################################################################
# Stage 1: Builder (compile TypeScript)
###############################################################################
-FROM node:18-slim AS builder
+FROM node:22-slim AS builder
WORKDIR /usr/src/microsoft-rewards-script
@@ -11,13 +11,13 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Copy package manifests
-COPY package*.json ./
+COPY package*.json tsconfig.json ./
# Conditional install: npm ci if lockfile exists, else npm install
RUN if [ -f package-lock.json ]; then \
- npm ci; \
+ npm ci --ignore-scripts; \
else \
- npm install; \
+ npm install --ignore-scripts; \
fi
# Copy source code
@@ -29,18 +29,16 @@ RUN npm run build
###############################################################################
# Stage 2: Runtime (Playwright image)
###############################################################################
-FROM mcr.microsoft.com/playwright:v1.52.0-jammy
+FROM node:22-slim AS runtime
WORKDIR /usr/src/microsoft-rewards-script
-# Install cron, gettext-base (for envsubst), tzdata noninteractively
-RUN apt-get update \
- && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
- cron gettext-base tzdata \
- && rm -rf /var/lib/apt/lists/*
-
-# Ensure Playwright uses preinstalled browsers
+ENV NODE_ENV=production
+ENV TZ=UTC
+# Use shared location for Playwright browsers so both 'playwright' and 'rebrowser-playwright' can find them
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+# Force headless in container to be compatible with Chromium Headless Shell
+ENV FORCE_HEADLESS=1
# Copy package files first for better caching
COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./
@@ -52,17 +50,14 @@ RUN if [ -f package-lock.json ]; then \
npm install --production --ignore-scripts; \
fi
+# Install only Chromium Headless Shell and its OS deps (smaller than full browser set)
+# This will install required apt packages internally; we clean up afterwards to keep the image slim.
+RUN npx playwright install --with-deps --only-shell \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin || true
+
# Copy built application
COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist
-# Copy runtime scripts with proper permissions from the start
-COPY --chmod=755 src/run_daily.sh ./src/run_daily.sh
-COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
-COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
-
-# Default TZ (overridden by user via environment)
-ENV TZ=UTC
-
-# Entrypoint handles TZ, initial run toggle, cron templating & launch
-ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
-CMD ["sh", "-c", "echo 'Container started; cron is running.'"]
+# Default command runs the built-in scheduler; can be overridden by docker-compose
+CMD ["npm", "run", "start:schedule"]
diff --git a/README.md b/README.md
index 7d82026..172ab02 100644
--- a/README.md
+++ b/README.md
@@ -1,191 +1,238 @@
-# Microsoft-Rewards-Script
-Automated Microsoft Rewards script built with TypeScript, Cheerio and Playwright.
+
-Under development, however mainly for personal use!
+# ๐ฏ Microsoft Rewards Script V2
+
+```
+ โโโโ โโโโโโโโโโโโ โโโโโโโ โโโโโโโโโโโ โโโ โโโโโโ โโโโโโโ โโโโโโโ โโโโโโโโ
+ โโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโ โโ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
+ โโโ โโโ โโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโ
+ โโโ โโโโโโโโโโโ โโโ โโโโโโโโโโโ โโโโโโโโ โโโ โโโโโโ โโโโโโโโโโ โโโโโโโโ
+```
+
+**๐ค Intelligent automation meets Microsoft Rewards**
+*Earn points effortlessly while you sleep*
+[Legacy-1.5.3](https://github.com/LightZirconite/Microsoft-Rewards-Script-Private/tree/Legacy-1.5.3)
+
+[](https://www.typescriptlang.org/)
+[](https://nodejs.org/)
+[](https://www.docker.com/)
+[](https://playwright.dev/)
+
+
+
+
+

+

+
+
---
-## ๐ Quick Setup (Recommended)
+
-**The easiest way to get started - just download and run!**
+## ๐ **Big Update Alert โ V2 is here!**
-1. **Download or clone** the source code
-2. **Run the setup script:**
-
- **Windows:** Double-click `setup/setup.bat` or run it from command line
-
- **Linux/macOS/WSL:** `bash setup/setup.sh`
-
- **Alternative (any platform):** `npm run setup`
+
+
+
+
+TheNetsky ๐
+Foundation Architect
+Building the massive foundation
+ |
+
+
+Mgrimace ๐ฅ
+Active Developer
+Regular updates & NTFY mode
+ |
+
+
+Light โจ
+V2 Mastermind
+Massive feature overhaul
+ |
+
+
-3. **Follow the prompts:** The setup script will automatically:
- - Rename `accounts.example.json` to `accounts.json`
- - Ask you to enter your Microsoft account credentials
- - Remind you to review configuration options in `config.json`
- - Install all dependencies (`npm install`)
- - Build the project (`npm run build`)
- - Optionally start the script immediately
+**๐ก Welcome to V2 โ There are honestly so many changes that even I can't list them all!**
+*Trust me, you've got a **massive upgrade** in front of you. Enjoy the ride!* ๐ข
-**That's it!** The setup script handles everything for you.
+
---
-## โ๏ธ Advanced Setup Options
+## ๐ฏ **What Does This Script Do?**
-### Nix Users
-1. Get [Nix](https://nixos.org/)
-2. Run `./run.sh`
-3. Done!
+
-### Manual Setup (Troubleshooting)
-If the automatic setup script doesn't work for your environment:
+**Automatically earn Microsoft Rewards points by completing daily tasks:**
+- ๐ **Daily Searches** โ Desktop & Mobile Bing searches
+- ๐
**Daily Set** โ Complete daily quizzes and activities
+- ๐ **Promotions** โ Bonus point opportunities
+- ๐ **Punch Cards** โ Multi-day reward challenges
+- โ
**Daily Check-in** โ Simple daily login rewards
+- ๐ **Read to Earn** โ News article reading points
-1. Manually rename `src/accounts.example.json` to `src/accounts.json`
-2. Add your Microsoft account details to `accounts.json`
-3. Customize `src/config.json` to your preferences
-4. Install dependencies: `npm install`
-5. Build the project: `npm run build`
-6. Start the script: `npm run start`---
+*All done automatically while you sleep! ๐ค*
-## ๐ณ Docker Setup (Experimental)
-
-For automated scheduling and containerized deployment.
-
-### Before Starting
-- Remove `/node_modules` and `/dist` folders if you previously built locally
-- Remove old Docker volumes if upgrading from version 1.4 or earlier
-- Old `accounts.json` files can be reused
-
-### Quick Docker Setup
-1. **Download source code** and configure `accounts.json`
-2. **Edit `config.json`** - ensure `"headless": true`
-3. **Customize `compose.yaml`:**
- - Set your timezone (`TZ` variable)
- - Configure schedule (`CRON_SCHEDULE`) - use [crontab.guru](https://crontab.guru) for help
- - Optional: Set `RUN_ON_START=true` for immediate execution
-4. **Start container:** `docker compose up -d`
-5. **Monitor logs:** `docker logs microsoft-rewards-script`
-
-**Note:** The container adds 5โ50 minutes random delay to scheduled runs for more natural behavior.
+
---
-## ๐ Usage Notes
+## โก Quick Start
-- **Browser Instances:** If you stop the script without closing browser windows (headless=false), use Task Manager or `npm run kill-chrome-win` to clean up
-- **Automation Scheduling:** Run at least twice daily, set `"runOnZeroPoints": false` to skip when no points available
-- **Multiple Accounts:** The script supports clustering - configure `clusters` in `config.json`
+```bash
+# ๐ช Windows โ One command setup
+setup/setup.bat
----
-## โ๏ธ Configuration Reference
+# ๐ง Linux/macOS/WSL
+bash setup/setup.sh
-Customize behavior by editing `src/config.json`:
+# ๐ Any platform
+npm run setup
+```
-### Core Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `baseURL` | Microsoft Rewards page URL | `https://rewards.bing.com` |
-| `sessionPath` | Session/fingerprint storage location | `sessions` |
-| `headless` | Run browser in background | `false` (visible) |
-| `parallel` | Run mobile/desktop tasks simultaneously | `true` |
-| `runOnZeroPoints` | Continue when no points available | `false` |
-| `clusters` | Number of concurrent account instances | `1` |
+**That's it!** The setup wizard configures accounts, installs dependencies, builds the project, and starts earning points.
-### Fingerprint Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `saveFingerprint.mobile` | Reuse mobile browser fingerprint | `false` |
-| `saveFingerprint.desktop` | Reuse desktop browser fingerprint | `false` |
+
+๐ Manual Setup
-### Task Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `workers.doDailySet` | Complete daily set activities | `true` |
-| `workers.doMorePromotions` | Complete promotional offers | `true` |
-| `workers.doPunchCards` | Complete punchcard activities | `true` |
-| `workers.doDesktopSearch` | Perform desktop searches | `true` |
-| `workers.doMobileSearch` | Perform mobile searches | `true` |
-| `workers.doDailyCheckIn` | Complete daily check-in | `true` |
-| `workers.doReadToEarn` | Complete read-to-earn activities | `true` |
+```bash
+# 1๏ธโฃ Configure your Microsoft accounts
+cp src/accounts.example.json src/accounts.json
+# Edit accounts.json with your credentials
-### Search Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `searchOnBingLocalQueries` | Use local queries vs. fetched | `false` |
-| `searchSettings.useGeoLocaleQueries` | Generate location-based queries | `false` |
-| `searchSettings.scrollRandomResults` | Randomly scroll search results | `true` |
-| `searchSettings.clickRandomResults` | Click random result links | `true` |
-| `searchSettings.searchDelay` | Delay between searches (min/max) | `3-5 minutes` |
-| `searchSettings.retryMobileSearchAmount` | Mobile search retry attempts | `2` |
+# 2๏ธโฃ Install & Build
+npm install && npm run build
-### Advanced Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `globalTimeout` | Action timeout duration | `30s` |
-| `logExcludeFunc` | Functions to exclude from logs | `SEARCH-CLOSE-TABS` |
-| `webhookLogExcludeFunc` | Functions to exclude from webhooks | `SEARCH-CLOSE-TABS` |
-| `proxy.proxyGoogleTrends` | Proxy Google Trends requests | `true` |
-| `proxy.proxyBingTerms` | Proxy Bing Terms requests | `true` |
+# 3๏ธโฃ Run once or start scheduler
+npm start # Single run
+npm run start:schedule # Automated daily runs
+```
-### Webhook Settings
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `webhook.enabled` | Enable Discord notifications | `false` |
-| `webhook.url` | Discord webhook URL | `null` |
-| `conclusionWebhook.enabled` | Enable summary-only webhook | `false` |
-| `conclusionWebhook.url` | Summary webhook URL | `null` |
+
---
-## โจ Features
+## ๐ Documentation
-**Account Management:**
-- โ
Multi-Account Support
-- โ
Session Storage & Persistence
-- โ
2FA Support
-- โ
Passwordless Login Support
+| Topic | Description |
+|-------|-------------|
+| **[๐ Getting Started](./docs/getting-started.md)** | Complete setup guide from zero to running |
+| **[๐ค Accounts & 2FA](./docs/accounts.md)** | Microsoft account setup + TOTP authentication |
+| **[๐ณ Docker](./docs/docker.md)** | Containerized deployment with slim headless image |
+| **[โฐ Scheduling](./docs/schedule.md)** | Automated daily runs with built-in scheduler |
+| **[๐ ๏ธ Diagnostics](./docs/diagnostics.md)** | Troubleshooting, error capture, and logs |
+| **[โ๏ธ Configuration](./docs/config.md)** | Full config.json reference |
-**Automation & Control:**
-- โ
Headless Browser Operation
-- โ
Clustering Support (Multiple accounts simultaneously)
-- โ
Configurable Task Selection
-- โ
Proxy Support
-- โ
Automatic Scheduling (Docker)
+**[๐ Full Documentation Index โ](./docs/index.md)**
-**Search & Activities:**
-- โ
Desktop & Mobile Searches
-- โ
Microsoft Edge Search Simulation
-- โ
Geo-Located Search Queries
-- โ
Emulated Scrolling & Link Clicking
-- โ
Daily Set Completion
-- โ
Promotional Activities
-- โ
Punchcard Completion
-- โ
Daily Check-in
-- โ
Read to Earn Activities
+## ๐ฎ Commands
-**Quiz & Interactive Content:**
-- โ
Quiz Solving (10 & 30-40 point variants)
-- โ
This Or That Quiz (Random answers)
-- โ
ABC Quiz Solving
-- โ
Poll Completion
-- โ
Click Rewards
+```bash
+# ๐ Run the automation once
+npm start
-**Notifications & Monitoring:**
-- โ
Discord Webhook Integration
-- โ
Dedicated Summary Webhook
-- โ
Comprehensive Logging
-- โ
Docker Support with Monitoring
+# ๏ฟฝ Start automated daily scheduler
+npm run start:schedule
+
+# ๐ณ Manual points redemption mode
+npm start -- -buy your@email.com
+
+# ๏ฟฝ Deploy with Docker
+docker compose up -d
+
+# ๏ฟฝ Development mode
+npm run dev
+```
---
+## โจ Key Features
+
+
+
+| Feature | Description |
+|---------|-------------|
+| **๐ Multi-Account** | Support multiple Microsoft accounts with 2FA |
+| **๐ค Human-like** | Natural delays, scrolling, clicking patterns |
+| **๐ฑ Cross-Platform** | Desktop + Mobile search automation |
+| **๐ฏ Smart Activities** | Quizzes, polls, daily sets, punch cards |
+| **๐ Notifications** | Discord webhooks + NTFY push alerts |
+| **๐ณ Docker Ready** | Slim headless container deployment |
+| **๐ก๏ธ Resilient** | Session persistence, job state recovery |
+| **๐ธ๏ธ Proxy Support** | Per-account proxy configuration |
+
+
+
+---
+
+## ๐ Advanced Features
+
+**[๐ณ Buy Mode](./docs/buy-mode.md)** โ Manual redemption with live points monitoring
+**[๐ง Humanization](./docs/humanization.md)** โ Advanced anti-detection patterns
+**[๐ Diagnostics](./docs/diagnostics.md)** โ Error capture with screenshots/HTML
+**[๐ Webhooks](./docs/conclusionwebhook.md)** โ Rich Discord notifications
+**[๐ฑ NTFY](./docs/ntfy.md)** โ Push notifications to your phone
+
+---
+
+## ๐ Documentation & Support
+
+
+
+**๐ [Complete Documentation Index](./docs/index.md)**
+
+
+
+### Essential Guides
+- **[Getting Started](./docs/getting-started.md)** โ Zero to running in minutes
+- **[Accounts Setup](./docs/accounts.md)** โ Microsoft accounts + 2FA configuration
+- **[Docker Guide](./docs/docker.md)** โ Container deployment
+- **[Scheduling](./docs/schedule.md)** โ Automated daily runs
+- **[Troubleshooting](./docs/diagnostics.md)** โ Fix common issues
+
+### Advanced Topics
+- **[Humanization](./docs/humanization.md)** โ Anti-detection features
+- **[Notifications](./docs/ntfy.md)** โ Push alerts & Discord webhooks
+- **[Proxy Setup](./docs/proxy.md)** โ Network configuration
+- **[Buy Mode](./docs/buy-mode.md)** โ Manual redemption tracking
+
+---
+
+## ๐ค Community
+
+
+
+[](https://discord.gg/KRBFxxsU)
+[](https://github.com/TheNetsky/Microsoft-Rewards-Script)
+
+**Found a bug?** [Report an issue](https://github.com/TheNetsky/Microsoft-Rewards-Script/issues)
+**Have suggestions?** [Start a discussion](https://github.com/TheNetsky/Microsoft-Rewards-Script/discussions)
+
+
+
+---
+
+
+
## โ ๏ธ Disclaimer
-**Use at your own risk!** Your Microsoft Rewards account may be suspended or banned when using automation scripts.
+This project is for educational purposes only. Use at your own risk. Microsoft may suspend accounts that use automation tools. The authors are not responsible for any account actions taken by Microsoft.
-This script is provided for educational purposes. The authors are not responsible for any account actions taken by Microsoft.
+**๐ฏ Contributors**
+
+
+
+
+
+*Made with โค๏ธ by the community โข Happy automating! ๐*
+
+
---
-## ๐ค Contributing
-
-This project is primarily for personal use but contributions are welcome. Please ensure any changes maintain compatibility with the existing configuration system.
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..accfc27
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,85 @@
+# Security & Privacy Policy
+
+Hi there! ๐ Thanks for caring about security and privacy โ we do too. This document explains how this project approaches data handling, security practices, and how to report issues responsibly.
+
+## TL;DR
+
+- We do not collect, phone-home, or exfiltrate your data. No hidden telemetry. ๐ซ๐ก
+- Your credentials stay on your machine (or in your container volumes). ๐
+- Sessions/cookies are stored locally to reduce re-login friction. ๐ช
+- Use at your own risk. Microsoft may take action on accounts that use automation.
+
+## What this project does (and doesnโt)
+
+This is a local automation tool that drives a browser (Playwright) to perform Microsoft Rewards activities. By default:
+
+- It reads configuration from local files (e.g., `src/config.json`, `src/accounts.json`).
+- It can save session data (cookies and optional fingerprints) locally under `./src/browser///`.
+- It can send optional notifications/webhooks if you enable them and provide a URL.
+
+It does not:
+
+- Send your accounts or secrets to any third-party service by default.
+- Embed any โphone-homeโ or analytics endpoints.
+- Include built-in monetization, miners, or adware. ๐ซ๐
+
+## Data handling and storage
+
+- Accounts: You control the `accounts.json` file. Keep it safe. Consider environment variables or secrets managers in CI/CD.
+- Sessions: Cookies are stored locally to speed up login. You can delete them anytime by removing the session folder.
+- Fingerprints: If you enable fingerprint saving, they are saved locally only. Disable this feature if you prefer ephemeral fingerprints.
+- Logs/Reports: Diagnostic artifacts and daily summaries are written to the local `reports/` directory.
+- Webhooks/Notifications: If enabled, we send only the minimal information necessary (e.g., summary text, embed fields) to the endpoint you configured.
+
+Tip: For Docker, mount a dedicated data volume for sessions and reports so you can manage them easily. ๐ฆ
+
+## Credentials and secrets
+
+- Do not commit secrets. Use `src/accounts.json` locally or set `ACCOUNTS_JSON`/`ACCOUNTS_FILE` via environment variables when running in containers.
+- Consider using OS keychains or external secret managers where possible.
+- TOTP: If you include a Base32 TOTP secret per account, it remains local and is used strictly during login challenge flows.
+
+## Buy Mode safety
+
+Buy Mode opens a monitor tab (read-only points polling) and a separate user tab for your manual actions. The monitor tab doesnโt redeem or click on your behalf โ it just reads dashboard data to keep totals up to date. ๐๏ธ
+
+## Responsible disclosure
+
+We value coordinated disclosure. If you find a security issue:
+
+1. Please report it privately first via an issue marked โSecurityโ with a note to request contact details, or by contacting the repository owner directly if available.
+2. Provide a minimal reproduction and version info.
+3. We will acknowledge within a reasonable timeframe and work on a fix. ๐
+
+Please do not open public issues with sensitive details before we have had a chance to remediate.
+
+## Scope and assumptions
+
+- This project is open-source and runs on your infrastructure (local machine or container). You are responsible for host hardening and network policies.
+- Automation can violate terms of service. You assume all responsibility for how you use this tool.
+- Browsers and dependencies evolve. Keep the project and your runtime up to date.
+
+## Dependency and update policy
+
+- We pin key dependencies where practical and avoid risky postinstall scripts in production builds.
+- Periodic updates are encouraged. The project includes an optional auto-update helper. Review changes before enabling in sensitive environments.
+- Use Playwright official images when running in containers to receive timely browser security updates. ๐ก๏ธ
+
+## Safe use guidelines
+
+- Run with least privileges. In Docker, prefer non-root where feasible and set `no-new-privileges` if supported.
+- Limit outbound network access if your threat model requires it.
+- Rotate credentials periodically and revoke unused secrets.
+- Clean up diagnostics and reports if they contain sensitive metadata.
+
+## Privacy statement
+
+We donโt collect personal data. The repository does not embed analytics. Any processing done by this tool happens locally or against the Microsoft endpoints it drives on your behalf.
+
+If you enable third-party notifications (Discord, NTFY, etc.), data sent there is under your control and subject to those servicesโ privacy policies.
+
+## Contact
+
+To report a security issue or ask a question, please open an issue with the โSecurityโ label and weโll follow up with a private channel. You can also reach out to the project owner/maintainers via GitHub if contact details are listed. ๐ฌ
+
+โ Stay safe and have fun automating! โจ
diff --git a/compose.yaml b/compose.yaml
index 0002100..28c4d88 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -6,37 +6,25 @@ services:
# Volume mounts: Specify a location where you want to save the files on your local machine.
volumes:
- - ./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
+ - ./src/accounts.json:/usr/src/microsoft-rewards-script/accounts.json:ro
+ - ./src/config.json:/usr/src/microsoft-rewards-script/config.json:ro
+ - ./sessions:/usr/src/microsoft-rewards-script/sessions # Optional, saves your login session
environment:
- TZ: "America/Toronto" # Set your timezone for proper scheduling
+ TZ: "America/Toronto" # Set your timezone for proper scheduling (used by image and scheduler)
NODE_ENV: "production"
- CRON_SCHEDULE: "0 7,16,20 * * *" # Customize your schedule, use crontab.guru for formatting
- RUN_ON_START: "true" # Runs the script immediately on container startup
+ # Force headless when running in Docker (uses Chromium Headless Shell only)
+ FORCE_HEADLESS: "1"
+ #SCHEDULER_DAILY_JITTER_MINUTES_MIN: "2"
+ #SCHEDULER_DAILY_JITTER_MINUTES_MAX: "10"
+ # Watchdog timeout per pass (minutes, default 180)
+ #SCHEDULER_PASS_TIMEOUT_MINUTES: "180"
+ # Run pass in child process (default true). Set to "false" to disable for debugging.
+ #SCHEDULER_FORK_PER_PASS: "true"
- # Add scheduled start-time randomization (uncomment to customize or disable, default: enabled)
- #MIN_SLEEP_MINUTES: "5"
- #MAX_SLEEP_MINUTES: "50"
- SKIP_RANDOM_SLEEP: "false"
-
- # Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours)
- #STUCK_PROCESS_TIMEOUT_HOURS: "8"
-
- # Optional resource limits for the container
- mem_limit: 4g
- cpus: 2
-
- # Health check - monitors if cron daemon is running to ensure scheduled jobs can execute
- # Container marked unhealthy if cron process dies
- healthcheck:
- test: ["CMD", "sh", "-c", "pgrep cron > /dev/null || exit 1"]
- interval: 60s
- timeout: 10s
- retries: 3
- start_period: 30s
-
# Security hardening
security_opt:
- - no-new-privileges:true
\ No newline at end of file
+ - no-new-privileges:true
+
+ # Use the built-in scheduler by default; override with `command:` for one-shot runs
+ command: ["npm", "run", "start:schedule"]
\ No newline at end of file
diff --git a/docs/accounts.md b/docs/accounts.md
new file mode 100644
index 0000000..9e09db3
--- /dev/null
+++ b/docs/accounts.md
@@ -0,0 +1,94 @@
+# ๐ค Accounts & TOTP (2FA)
+
+
+
+**๐ Secure Microsoft account setup with 2FA support**
+*Everything you need to configure authentication*
+
+
+
+---
+
+## ๐ File Location & Options
+
+The bot needs Microsoft account credentials to log in and complete activities. Here's how to provide them:
+
+### **Default Location**
+```
+src/accounts.json
+```
+
+### **Environment Overrides** (Docker/CI)
+- **`ACCOUNTS_FILE`** โ Path to accounts file (e.g., `/data/accounts.json`)
+- **`ACCOUNTS_JSON`** โ Inline JSON string (useful for CI/CD)
+
+The loader tries: `ACCOUNTS_JSON` โ `ACCOUNTS_FILE` โ default locations in project root.
+
+## Schema
+Each account has at least `email` and `password`.
+
+```
+{
+ "accounts": [
+ {
+ "email": "email_1",
+ "password": "password_1",
+ "totp": "",
+ "recoveryEmail": "your_email@domain.com",
+ "proxy": {
+ "proxyAxios": true,
+ "url": "",
+ "port": 0,
+ "username": "",
+ "password": ""
+ }
+ }
+ ]
+}
+```
+
+- `totp` (optional): Base32 secret for Timeโbased OneโTime Passwords (2FA). If set, the bot generates the 6โdigit code automatically when asked by Microsoft.
+- `recoveryEmail` (optional): used to validate masked recovery prompts.
+- `proxy` (optional): perโaccount proxy config. See the [Proxy guide](./proxy.md).
+
+## How to get your TOTP secret
+1) In your Microsoft account security settings, add an authenticator app.
+2) When shown the QR code, choose the option to enter the code manually โ this reveals the Base32 secret.
+3) Copy that secret (only the text after `secret=` if you have an otpauth URL) into the `totp` field.
+
+Security tips:
+- Never commit real secrets to Git.
+- Prefer `ACCOUNTS_FILE` or `ACCOUNTS_JSON` in production.
+
+## Examples
+- Single account, no 2FA:
+```
+{"accounts":[{"email":"a@b.com","password":"pass","totp":"","recoveryEmail":"","proxy":{"proxyAxios":true,"url":"","port":0,"username":"","password":""}}]}
+```
+
+- Single account with TOTP secret:
+```
+{"accounts":[{"email":"a@b.com","password":"pass","totp":"JBSWY3DPEHPK3PXP","recoveryEmail":"","proxy":{"proxyAxios":true,"url":"","port":0,"username":"","password":""}}]}
+```
+
+- Multiple accounts:
+```
+{"accounts":[
+ {"email":"a@b.com","password":"pass","totp":"","recoveryEmail":"" ,"proxy":{"proxyAxios":true,"url":"","port":0,"username":"","password":""}},
+ {"email":"c@d.com","password":"pass","totp":"","recoveryEmail":"" ,"proxy":{"proxyAxios":true,"url":"","port":0,"username":"","password":""}}
+]}
+```
+
+## Troubleshooting
+- โaccounts file not foundโ: ensure the file exists, or set `ACCOUNTS_FILE` to the correct path.
+- 2FA prompt not filled: verify `totp` is a valid Base32 secret; time on the host/container should be correct.
+- Locked account: the bot will log and skip; resolve manually then reโenable.
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Docker](./docker.md)** โ Container deployment with accounts
+- **[Security](./security.md)** โ Account protection and incident response
+- **[NTFY Notifications](./ntfy.md)** โ Get alerts for login issues
\ No newline at end of file
diff --git a/docs/buy-mode.md b/docs/buy-mode.md
new file mode 100644
index 0000000..4f382d0
--- /dev/null
+++ b/docs/buy-mode.md
@@ -0,0 +1,206 @@
+# ๐ณ Buy Mode
+
+
+
+**๐ Manual redemption with live point monitoring**
+*Track your spending while maintaining full control*
+
+
+
+---
+
+## ๐ฏ What is Buy Mode?
+
+Buy Mode allows you to **manually redeem rewards** while the script **passively monitors** your point balance. Perfect for safe redemptions without automation interference.
+
+### **Key Features**
+- ๐ **Passive monitoring** โ No clicks or automation
+- ๐ **Real-time tracking** โ Instant spending alerts
+- ๐ฑ **Live notifications** โ Discord/NTFY integration
+- โฑ๏ธ **Configurable duration** โ Set your own time limit
+- ๐ **Session summary** โ Complete spending report
+
+---
+
+## ๐ How to Use
+
+### **Command Options**
+```bash
+# Monitor specific account
+npm start -- -buy your@email.com
+
+# Monitor first account in accounts.json
+npm start -- -buy
+
+# Alternative: Enable in config (see below)
+```
+
+### **What Happens Next**
+1. **๐ฅ๏ธ Dual Tab System Opens**
+ - **Monitor Tab** โ Background monitoring (auto-refresh)
+ - **User Tab** โ Your control for redemptions/browsing
+
+2. **๐ Passive Point Tracking**
+ - Reads balance every ~10 seconds
+ - Detects spending when points decrease
+ - Zero interference with your browsing
+
+3. **๐ Real-time Alerts**
+ - Instant notifications when spending detected
+ - Shows amount spent + current balance
+ - Tracks cumulative session spending
+
+---
+
+## โ๏ธ Configuration
+
+### **Set Duration in Config**
+Add to `src/config.json`:
+```json
+{
+ "buyMode": {
+ "enabled": false,
+ "maxMinutes": 45
+ }
+}
+```
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `false` | Force buy mode without CLI flag |
+| `maxMinutes` | `45` | Auto-stop after N minutes |
+
+### **Enable Notifications**
+Buy mode works with existing notification settings:
+```json
+{
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/YOUR_URL"
+ },
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "rewards"
+ }
+}
+```
+
+---
+
+## ๐ฅ๏ธ Terminal Output
+
+### **Startup**
+```
+ โโโโ โโโโโโโโโโโโ โโโโโโโ โโโ โโโโโโ โโโ
+ โโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโ โโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโ
+ โโโ โโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โโโ
+ โโโ โโโโโโโโโโโ โโโโโโโ โโโโโโโ โโโ
+
+ Manual Purchase Mode โข Passive Monitoring
+
+[BUY-MODE] Opening dual-tab system for safe redemptions...
+[BUY-MODE] Monitor tab: Background point tracking
+[BUY-MODE] User tab: Your control for purchases/browsing
+```
+
+### **Live Monitoring**
+```
+[BUY-MODE] Current balance: 15,000 points
+[BUY-MODE] ๐ Spending detected: -500 points (new balance: 14,500)
+[BUY-MODE] Session total spent: 500 points
+```
+
+---
+
+## ๐ Use Cases
+
+| Scenario | Benefit |
+|----------|---------|
+| **๐ Gift Card Redemption** | Track exact point cost while redeeming safely |
+| **๐๏ธ Microsoft Store Purchases** | Monitor spending across multiple items |
+| **โ
Account Verification** | Ensure point changes match expected activity |
+| **๐ Spending Analysis** | Real-time tracking of reward usage patterns |
+| **๐ Safe Browsing** | Use Microsoft Rewards normally with monitoring |
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **Monitor tab closes** | Script auto-reopens in background |
+| **No spending alerts** | Check webhook/NTFY config; verify notifications enabled |
+| **Session too short** | Increase `maxMinutes` in config |
+| **Login failures** | Verify account credentials in `accounts.json` |
+| **Points not updating** | Check internet connection; try refresh |
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Accounts & 2FA](./accounts.md)** โ Microsoft account setup
+- **[NTFY Notifications](./ntfy.md)** โ Mobile push alerts
+- **[Discord Webhooks](./conclusionwebhook.md)** โ Server notifications
+
+## Terminal Output
+
+When you start buy mode, you'll see:
+
+```
+ โโโโ โโโโโโโโโโโโ โโโโโโโ โโโ โโโโโโ โโโ
+ โโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโ โโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโ
+ โโโ โโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โโโ
+ โโโ โโโโโโโโโโโ โโโโโโโ โโโโโโโ โโโ
+
+ Manual Purchase Mode โข Passive Monitoring
+
+[BUY-MODE] Buy mode ENABLED for your@email.com. We'll open 2 tabs:
+ (1) monitor tab (auto-refreshes), (2) your browsing tab
+[BUY-MODE] The monitor tab may refresh every ~10s. Use the other tab...
+[BUY-MODE] Opened MONITOR tab (auto-refreshes to track points)
+[BUY-MODE] Opened USER tab (use this one to redeem/purchase freely)
+[BUY-MODE] Logged in as your@email.com. Buy mode is active...
+```
+
+During monitoring:
+```
+[BUY-MODE] Detected spend: -500 points (current: 12,500)
+[BUY-MODE] Monitor tab was closed; reopening in background...
+```
+
+## Features
+
+- โ
**Non-intrusive**: No clicks or navigation in your browsing tab
+- โ
**Real-time alerts**: Instant notifications when points are spent
+- โ
**Auto-recovery**: Reopens monitor tab if accidentally closed
+- โ
**Webhook support**: Works with Discord and NTFY notifications
+- โ
**Configurable duration**: Set your own monitoring time limit
+- โ
**Session tracking**: Complete summary of spending activity
+
+## Use Cases
+
+- **Manual redemptions**: Redeem gift cards or rewards while tracking spending
+- **Account verification**: Monitor point changes during manual account activities
+- **Spending analysis**: Track how points are being used in real-time
+- **Safe browsing**: Use Microsoft Rewards normally while monitoring balance
+
+## Notes
+
+- Monitor tab runs in background and may refresh periodically
+- Your main browsing tab is completely under your control
+- Session data is saved automatically for future script runs
+- Buy mode works with existing notification configurations
+- No automation or point collection occurs in this mode
+
+## Troubleshooting
+
+- **Monitor tab closed**: Script automatically reopens it in background
+- **No notifications**: Check webhook/NTFY configuration in `config.json`
+- **Session timeout**: Increase `maxMinutes` if you need longer monitoring
+- **Login issues**: Ensure account credentials are correct in `accounts.json`
diff --git a/docs/conclusionwebhook.md b/docs/conclusionwebhook.md
new file mode 100644
index 0000000..f318773
--- /dev/null
+++ b/docs/conclusionwebhook.md
@@ -0,0 +1,389 @@
+# ๐ Discord Conclusion Webhook
+
+
+
+**๐ฏ Comprehensive session summaries via Discord**
+*Complete execution reports delivered instantly*
+
+
+
+---
+
+## ๐ฏ What is the Conclusion Webhook?
+
+The conclusion webhook sends a **detailed summary notification** at the end of each script execution via Discord, providing a complete overview of the session's results across all accounts.
+
+### **Key Features**
+- ๐ **Session overview** โ Total accounts processed, success/failure counts
+- ๐ **Points summary** โ Starting points, earned points, final totals
+- โฑ๏ธ **Performance metrics** โ Execution times, efficiency statistics
+- โ **Error reporting** โ Issues encountered during execution
+- ๐ณ **Buy mode detection** โ Point spending alerts and tracking
+- ๐จ **Rich embeds** โ Color-coded, well-formatted Discord messages
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "notifications": {
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/123456789/abcdef-webhook-token-here"
+ }
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Description | Example |
+|---------|-------------|---------|
+| `enabled` | Enable conclusion webhook | `true` |
+| `url` | Discord webhook URL | Full webhook URL from Discord |
+
+---
+
+## ๐ Discord Setup
+
+### **Step 1: Create Webhook**
+1. **Open Discord** and go to your server
+2. **Right-click** on the channel for notifications
+3. **Select "Edit Channel"**
+4. **Go to "Integrations" tab**
+5. **Click "Create Webhook"**
+
+### **Step 2: Configure Webhook**
+- **Name** โ "MS Rewards Summary"
+- **Avatar** โ Upload rewards icon (optional)
+- **Channel** โ Select appropriate channel
+- **Copy webhook URL**
+
+### **Step 3: Add to Config**
+```json
+{
+ "notifications": {
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "YOUR_COPIED_WEBHOOK_URL_HERE"
+ }
+ }
+}
+```
+
+---
+
+## ๐ Message Format
+
+### **Rich Embed Summary**
+
+#### **Header Section**
+```
+๐ฏ Microsoft Rewards Summary
+โฐ Completed at 2025-01-20 14:30:15
+๐ Total Runtime: 25m 36s
+```
+
+#### **Account Statistics**
+```
+๐ Accounts: 3 โข 0 with issues
+```
+
+#### **Points Overview**
+```
+๐ Points: 15,230 โ 16,890 (+1,660)
+```
+
+#### **Performance Metrics**
+```
+โฑ๏ธ Average Duration: 8m 32s
+๐ Cumulative Runtime: 25m 36s
+```
+
+#### **Buy Mode Detection** (if applicable)
+```
+๐ณ Buy Mode Activity Detected
+Total Spent: 1,200 points across 2 accounts
+```
+
+### **Account Breakdown**
+
+#### **Successful Account**
+```
+๐ค user@example.com
+Points: 5,420 โ 6,140 (+720)
+Duration: 7m 23s
+Status: โ
Completed successfully
+```
+
+#### **Failed Account**
+```
+๐ค problem@example.com
+Points: 3,210 โ 3,210 (+0)
+Duration: 2m 15s
+Status: โ Failed - Login timeout
+```
+
+#### **Buy Mode Account**
+```
+๐ณ spender@example.com
+Session Spent: 500 points
+Available: 12,500 points
+Status: ๐ณ Purchase activity detected
+```
+
+---
+
+## ๐ Message Examples
+
+### **Successful Session**
+```discord
+๐ฏ Microsoft Rewards Summary
+
+๐ Accounts: 3 โข 0 with issues
+๐ Points: 15,230 โ 16,890 (+1,660)
+โฑ๏ธ Average Duration: 8m 32s
+๐ Cumulative Runtime: 25m 36s
+
+๐ค user1@example.com
+Points: 5,420 โ 6,140 (+720)
+Duration: 7m 23s
+Status: โ
Completed successfully
+
+๐ค user2@example.com
+Points: 4,810 โ 5,750 (+940)
+Duration: 9m 41s
+Status: โ
Completed successfully
+
+๐ค user3@example.com
+Points: 5,000 โ 5,000 (+0)
+Duration: 8m 32s
+Status: โ
Completed successfully
+```
+
+### **Session with Issues**
+```discord
+๐ฏ Microsoft Rewards Summary
+
+๐ Accounts: 3 โข 1 with issues
+๐ Points: 15,230 โ 15,950 (+720)
+โฑ๏ธ Average Duration: 6m 15s
+๐ Cumulative Runtime: 18m 45s
+
+๐ค user1@example.com
+Points: 5,420 โ 6,140 (+720)
+Duration: 7m 23s
+Status: โ
Completed successfully
+
+๐ค user2@example.com
+Points: 4,810 โ 4,810 (+0)
+Duration: 2m 15s
+Status: โ Failed - Login timeout
+
+๐ค user3@example.com
+Points: 5,000 โ 5,000 (+0)
+Duration: 9m 07s
+Status: โ ๏ธ Partially completed - Quiz failed
+```
+
+### **Buy Mode Detection**
+```discord
+๐ฏ Microsoft Rewards Summary
+
+๐ Accounts: 2 โข 0 with issues
+๐ Points: 25,500 โ 24,220 (-1,280)
+๐ณ Buy Mode Activity Detected
+Total Spent: 1,500 points across 1 account
+
+๐ค buyer@example.com
+Points: 15,000 โ 13,500 (-1,500)
+Duration: 12m 34s
+Status: ๐ณ Buy mode detected
+Activities: Purchase completed, searches skipped
+
+๐ค normal@example.com
+Points: 10,500 โ 10,720 (+220)
+Duration: 8m 45s
+Status: โ
Completed successfully
+```
+
+---
+
+## ๐ค Integration with Other Notifications
+
+### **Webhook vs Conclusion Webhook**
+
+| Feature | Real-time Webhook | Conclusion Webhook |
+|---------|------------------|-------------------|
+| **Timing** | During execution | End of session only |
+| **Content** | Errors, warnings, progress | Comprehensive summary |
+| **Frequency** | Multiple per session | One per session |
+| **Purpose** | Immediate alerts | Session overview |
+
+### **Recommended Combined Setup**
+```json
+{
+ "notifications": {
+ "webhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/.../real-time"
+ },
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/.../summary"
+ },
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "rewards-mobile"
+ }
+ }
+}
+```
+
+### **Benefits of Combined Setup**
+- โก **Real-time webhook** โ Immediate error alerts
+- ๐ **Conclusion webhook** โ Comprehensive session summary
+- ๐ฑ **NTFY** โ Mobile notifications for critical issues
+
+---
+
+## ๐๏ธ Advanced Configuration
+
+### **Multiple Webhooks**
+```json
+{
+ "notifications": {
+ "webhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/.../errors-channel"
+ },
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/.../summary-channel"
+ }
+ }
+}
+```
+
+### **Channel Organization**
+
+#### **Recommended Discord Structure**
+- **#rewards-errors** โ Real-time error notifications (webhook)
+- **#rewards-summary** โ End-of-run summaries (conclusionWebhook)
+- **#rewards-logs** โ Detailed text logs (manual uploads)
+
+#### **Channel Settings**
+- **Notification settings** โ Configure per your preference
+- **Webhook permissions** โ Limit to specific channels
+- **Message history** โ Enable for tracking trends
+
+---
+
+## ๐ Security & Privacy
+
+### **Webhook Security Best Practices**
+- ๐ Use **dedicated Discord server** for notifications
+- ๐ฏ **Limit permissions** to specific channels only
+- ๐ **Regenerate URLs** if compromised
+- ๐ซ **Don't share** webhook URLs publicly
+
+### **Data Transmission**
+- โ
**Summary statistics** only
+- โ
**Points and email** addresses
+- โ **No passwords** or sensitive tokens
+- โ **No personal information** beyond emails
+
+### **Data Retention**
+- ๐พ **Discord stores** messages per server settings
+- ๐๏ธ **No local storage** by the script
+- โ๏ธ **Manual deletion** possible anytime
+- ๐ **Webhook logs** may be retained by Discord
+
+---
+
+## ๐งช Testing & Debugging
+
+### **Manual Webhook Test**
+```bash
+curl -X POST \
+ -H "Content-Type: application/json" \
+ -d '{"content":"Test message from rewards script"}' \
+ "YOUR_WEBHOOK_URL_HERE"
+```
+
+### **Script Debug Mode**
+```powershell
+$env:DEBUG_REWARDS_VERBOSE=1; npm start
+```
+
+### **Success Indicators**
+```
+[INFO] Sending conclusion webhook...
+[INFO] Conclusion webhook sent successfully
+```
+
+### **Error Messages**
+```
+[ERROR] Failed to send conclusion webhook: Invalid webhook URL
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **No summary received** | Check webhook URL; verify Discord permissions |
+| **Malformed messages** | Validate webhook URL; check Discord server status |
+| **Missing information** | Ensure script completed; check for execution errors |
+| **Rate limited** | Single webhook per session prevents this |
+
+### **Common Fixes**
+- โ
**Webhook URL** โ Must be complete Discord webhook URL
+- โ
**Channel permissions** โ Webhook must have send permissions
+- โ
**Server availability** โ Discord server must be accessible
+- โ
**Script completion** โ Summary only sent after full execution
+
+---
+
+## โก Performance Impact
+
+### **Resource Usage**
+- ๐จ **Single HTTP request** at script end
+- โก **Non-blocking operation** โ No execution delays
+- ๐พ **Payload size** โ Typically < 2KB
+- ๐ **Delivery time** โ Usually < 1 second
+
+### **Benefits**
+- โ
**No impact** on account processing
+- โ
**Minimal memory** footprint
+- โ
**No disk storage** required
+- โ
**Negligible bandwidth** usage
+
+---
+
+## ๐จ Customization
+
+### **Embed Features**
+- ๐จ **Color-coded** status indicators
+- ๐ญ **Emoji icons** for visual clarity
+- ๐ **Structured fields** for easy reading
+- โฐ **Timestamps** and duration info
+
+### **Discord Integration**
+- ๐ฌ **Thread notifications** support
+- ๐ฅ **Role mentions** (configure in webhook)
+- ๐ **Searchable messages** for history
+- ๐ **Archive functionality** for records
+
+---
+
+## ๐ Related Guides
+
+- **[NTFY Notifications](./ntfy.md)** โ Mobile push notifications
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Buy Mode](./buy-mode.md)** โ Manual purchasing with monitoring
+- **[Security](./security.md)** โ Privacy and data protection
\ No newline at end of file
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 0000000..aeb040b
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,227 @@
+# โ๏ธ Configuration Guide
+
+This page documents every field in `config.json`. You can keep the file lean by deleting blocks you do not use โ missing values fall back to defaults. Comments (`// ...`) are supported in the JSON thanks to a custom parser.
+
+> NOTE: Previous versions had `logging.live` (live streaming webhook); it was removed and replaced by a simple `logging.redactEmails` flag.
+
+---
+## Top-Level Fields
+
+### baseURL
+Internal Microsoft Rewards base. Leave it unless you know what you are doing.
+
+### sessionPath
+Directory where session data (cookies / fingerprints / job-state) is stored.
+
+---
+## browser
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| headless | boolean | false | Run browser UI-less. Setting to `false` can improve stability or help visual debugging. |
+| globalTimeout | string/number | "30s" | Max time for common Playwright operations. Accepts ms number or time string (e.g. `"45s"`, `"2min"`). |
+
+---
+## execution
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| parallel | boolean | false | Run desktop + mobile simultaneously (higher resource usage). |
+| runOnZeroPoints | boolean | false | Skip full run early if there are zero points available (saves time). |
+| clusters | number | 1 | Number of process clusters (multi-process concurrency). |
+| passesPerRun | number | 1 | Advanced: extra full passes per started run. |
+
+---
+## buyMode
+Manual redeem / purchase assistance.
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled (CLI `-buy`) | boolean | false | Enable buy mode (usually via CLI argument). |
+| maxMinutes | number | 45 | Max session length for buy mode. |
+
+---
+## fingerprinting.saveFingerprint
+Persist browser fingerprints per device type for consistency.
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| mobile | boolean | false | Save/reuse a consistent mobile fingerprint. |
+| desktop | boolean | false | Save/reuse a consistent desktop fingerprint. |
+
+---
+## search
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| useLocalQueries | boolean | false | Use locale-specific query sources instead of global ones. |
+
+### search.settings
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| useGeoLocaleQueries | boolean | false | Blend geo / locale into chosen queries. |
+| scrollRandomResults | boolean | true | Random scroll during search pages to look natural. |
+| clickRandomResults | boolean | true | Occasionally click safe results. |
+| retryMobileSearchAmount | number | 2 | Retries if mobile searches didnโt yield points. |
+| delay.min / delay.max | string/number | 3โ5min | Delay between searches (ms or time string). |
+
+---
+## humanization
+Humanโlike behavior simulation.
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled | boolean | true | Global on/off. |
+| stopOnBan | boolean | true | Stop processing further accounts if a ban is detected. |
+| immediateBanAlert | boolean | true | Fire notification immediately upon ban detection. |
+| actionDelay.min/max | number/string | 150โ450ms | Random micro-delay per action. |
+| gestureMoveProb | number | 0.4 | Probability of a small mouse move gesture. |
+| gestureScrollProb | number | 0.2 | Probability of a small scroll gesture. |
+| allowedWindows | string[] | [] | Local time windows (e.g. `["08:30-11:00","19:00-22:00"]`). Outside windows, run waits. |
+
+---
+## vacation
+Random contiguous block of days off per month.
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| enabled | boolean | false | Activate monthly break behavior. |
+| minDays | number | 3 | Minimum skipped days per month. |
+| maxDays | number | 5 | Maximum skipped days per month. |
+
+---
+## retryPolicy
+Generic transient retry/backoff.
+| Key | Type | Default | Description |
+|-----|------|---------|-------------|
+| maxAttempts | number | 3 | Max tries for retryable blocks. |
+| baseDelay | number | 1000 | Initial delay in ms. |
+| maxDelay | number/string | 30s | Max backoff delay. |
+| multiplier | number | 2 | Exponential backoff multiplier. |
+| jitter | number | 0.2 | Randomization factor (0..1). |
+
+---
+## workers
+Enable/disable scripted task categories.
+| Key | Default | Description |
+|-----|---------|-------------|
+| doDailySet | true | Daily set activities. |
+| doMorePromotions | true | Promotional tasks. |
+| doPunchCards | true | Punch card flows. |
+| doDesktopSearch | true | Desktop searches. |
+| doMobileSearch | true | Mobile searches. |
+| doDailyCheckIn | true | Daily check-in. |
+| doReadToEarn | true | Reading tasks. |
+| bundleDailySetWithSearch | false | Immediately start desktop search bundle after daily set. |
+
+---
+## proxy
+| Key | Default | Description |
+|-----|---------|-------------|
+| proxyGoogleTrends | true | Route Google Trends fetch through proxy if set. |
+| proxyBingTerms | true | Route Bing query source fetch through proxy if set. |
+
+---
+## notifications
+Manages notification channels (Discord webhooks, NTFY, etc.).
+
+### notifications.webhook
+Primary webhook (can be used for summary or generic messages).
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | false | Allow sending webhook-based notifications. |
+| url | "" | Webhook endpoint. |
+
+### notifications.conclusionWebhook
+Rich end-of-run summary (if enabled separately).
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | false | Enable run summary posting. |
+| url | "" | Webhook endpoint. |
+
+### notifications.ntfy
+Lightweight push notifications.
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | false | Enable NTFY push. |
+| url | "" | Base NTFY server URL (e.g. https://ntfy.sh). |
+| topic | rewards | Topic/channel name. |
+| authToken | "" | Bearer token if your server requires auth. |
+
+---
+## logging
+| Key | Type | Description |
+|-----|------|-------------|
+| excludeFunc | string[] | Log buckets suppressed in console + any webhook usage. |
+| webhookExcludeFunc | string[] | Buckets suppressed specifically for webhook output. |
+| redactEmails | boolean | If true, email addresses are partially masked in logs. |
+
+_Removed fields_: `live.enabled`, `live.url`, `live.redactEmails` โ replaced by `redactEmails` only.
+
+---
+## diagnostics
+Capture evidence when something fails.
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | true | Master switch for diagnostics. |
+| saveScreenshot | true | Save screenshot on failure. |
+| saveHtml | true | Save HTML snapshot on failure. |
+| maxPerRun | 2 | Cap artifacts per run per failure type. |
+| retentionDays | 7 | Old run artifacts pruned after this many days. |
+
+---
+## jobState
+Checkpoint system to avoid duplicate work.
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | true | Enable job state tracking. |
+| dir | "" | Custom directory (default: `/job-state`). |
+
+---
+## schedule
+Built-in scheduler (avoids external cron inside container or host).
+| Key | Default | Description |
+|-----|---------|-------------|
+| enabled | false | Enable scheduling loop. |
+| useAmPm | false | If true, parse `time12`; else use `time24`. |
+| time12 | 9:00 AM | 12โhour format time (only if useAmPm=true). |
+| time24 | 09:00 | 24โhour format time (only if useAmPm=false). |
+| timeZone | America/New_York | IANA zone string (e.g. Europe/Paris). |
+| runImmediatelyOnStart | false | Run one pass instantly in addition to daily schedule. |
+
+_Legacy_: If both `time12` and `time24` are empty, a legacy `time` (HH:mm) may still be read.
+
+---
+## update
+Auto-update behavior after a run.
+| Key | Default | Description |
+|-----|---------|-------------|
+| git | true | Pull latest git changes after run. |
+| docker | false | Recreate container (if running in Docker orchestration). |
+| scriptPath | setup/update/update.mjs | Custom script executed for update flow. |
+
+---
+## Security / Best Practices
+- Keep `redactEmails` true if you share logs publicly.
+- Use a private NTFY instance or secure Discord webhooks (do not leak URLs).
+- Avoid setting `headless` false on untrusted remote servers.
+
+---
+## Minimal Example
+```jsonc
+{
+ "browser": { "headless": true },
+ "execution": { "parallel": false },
+ "workers": { "doDailySet": true, "doDesktopSearch": true, "doMobileSearch": true },
+ "logging": { "redactEmails": true }
+}
+```
+
+## Common Tweaks
+| Goal | Change |
+|------|--------|
+| Faster dev feedback | Set `browser.headless` to false and shorten search delays. |
+| Reduce detection risk | Keep humanization enabled, add vacation window. |
+| Silent mode | Add more buckets to `excludeFunc`. |
+| Skip mobile searches | Set `workers.doMobileSearch=false`. |
+| Use daily schedule | Set `schedule.enabled=true` and adjust `time24` + `timeZone`. |
+
+---
+## Changelog Notes
+- Removed live webhook streaming complexity; now simpler logging.
+- Centralized redaction logic under `logging.redactEmails`.
+
+If something feels undocumented or unclear, open a documentation issue or extend this page.
diff --git a/docs/diagnostics.md b/docs/diagnostics.md
new file mode 100644
index 0000000..72499df
--- /dev/null
+++ b/docs/diagnostics.md
@@ -0,0 +1,225 @@
+# ๐ Diagnostics & Error Capture
+
+
+
+**๐ ๏ธ Automatic error screenshots and HTML snapshots**
+*Debug smarter with visual evidence*
+
+
+
+---
+
+## ๐ฏ What is Diagnostics?
+
+The diagnostics system **automatically captures** error screenshots and HTML snapshots when issues occur during script execution, providing visual evidence for troubleshooting.
+
+### **Key Features**
+- ๐ธ **Auto-screenshot** โ Visual error capture
+- ๐ **HTML snapshots** โ Complete page source
+- ๐ฆ **Rate limiting** โ Prevents storage bloat
+- ๐๏ธ **Auto-cleanup** โ Configurable retention
+- ๐ **Privacy-safe** โ Local storage only
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+Add to `src/config.json`:
+```json
+{
+ "diagnostics": {
+ "enabled": true,
+ "saveScreenshot": true,
+ "saveHtml": true,
+ "maxPerRun": 2,
+ "retentionDays": 7
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `true` | Master toggle for diagnostics capture |
+| `saveScreenshot` | `true` | Capture PNG screenshots on errors |
+| `saveHtml` | `true` | Save page HTML content on errors |
+| `maxPerRun` | `2` | Maximum captures per script run |
+| `retentionDays` | `7` | Auto-delete reports older than N days |
+
+---
+
+## ๐ How It Works
+
+### **Automatic Triggers**
+The system captures when these errors occur:
+- โฑ๏ธ **Page navigation timeouts**
+- ๐ฏ **Element selector failures**
+- ๐ **Authentication errors**
+- ๐ **Network request failures**
+- โก **JavaScript execution errors**
+
+### **Capture Process**
+1. **Error Detection** โ Script encounters unhandled error
+2. **Visual Capture** โ Screenshot + HTML snapshot
+3. **Safe Storage** โ Local `reports/` folder
+4. **Continue Execution** โ No blocking or interruption
+
+---
+
+## ๐ File Structure
+
+### **Storage Organization**
+```
+reports/
+โโโ 2025-01-20/
+โ โโโ error_abc123_001.png
+โ โโโ error_abc123_001.html
+โ โโโ error_def456_002.png
+โ โโโ error_def456_002.html
+โโโ 2025-01-21/
+ โโโ ...
+```
+
+### **File Naming Convention**
+```
+error_[runId]_[sequence].[ext]
+```
+- **RunId** โ Unique identifier for each script execution
+- **Sequence** โ Incremental counter (001, 002, etc.)
+- **Extension** โ `.png` for screenshots, `.html` for source
+
+---
+
+## ๐งน Retention Management
+
+### **Automatic Cleanup**
+- Runs after each script completion
+- Deletes entire date folders older than `retentionDays`
+- Prevents unlimited disk usage growth
+
+### **Manual Cleanup**
+```powershell
+# Remove all diagnostic reports
+Remove-Item -Recurse -Force reports/
+
+# Remove reports older than 3 days
+Get-ChildItem reports/ | Where-Object {$_.LastWriteTime -lt (Get-Date).AddDays(-3)} | Remove-Item -Recurse -Force
+```
+
+---
+
+## ๐ Use Cases
+
+| Scenario | Benefit |
+|----------|---------|
+| **๐ Development & Debugging** | Visual confirmation of page state during errors |
+| **๐ Element Detection Issues** | HTML source analysis for selector problems |
+| **๐ Production Monitoring** | Evidence collection for account issues |
+| **โก Performance Analysis** | Timeline reconstruction of automation failures |
+
+---
+
+## โก Performance Impact
+
+### **Resource Usage**
+- **Screenshots** โ ~100-500KB each
+- **HTML files** โ ~50-200KB each
+- **CPU overhead** โ Minimal (only during errors)
+- **Memory impact** โ Asynchronous, non-blocking
+
+### **Storage Optimization**
+- Daily cleanup prevents accumulation
+- Rate limiting via `maxPerRun`
+- Configurable retention period
+
+---
+
+## ๐๏ธ Environment Settings
+
+### **Development Mode**
+```json
+{
+ "diagnostics": {
+ "enabled": true,
+ "maxPerRun": 5,
+ "retentionDays": 14
+ }
+}
+```
+
+### **Production Mode**
+```json
+{
+ "diagnostics": {
+ "enabled": true,
+ "maxPerRun": 2,
+ "retentionDays": 3
+ }
+}
+```
+
+### **Debug Verbose Logging**
+```powershell
+$env:DEBUG_REWARDS_VERBOSE=1; npm start
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **No captures despite errors** | Check `enabled: true`; verify `reports/` write permissions |
+| **Excessive storage usage** | Reduce `maxPerRun`; decrease `retentionDays` |
+| **Missing screenshots** | Verify browser screenshot API; check memory availability |
+| **Cleanup not working** | Ensure script completes successfully for auto-cleanup |
+
+### **Common Capture Locations**
+- **Login issues** โ Authentication page screenshots
+- **Activity failures** โ Element detection errors
+- **Network problems** โ Timeout and connection errors
+- **Navigation issues** โ Page load failures
+
+---
+
+## ๐ Integration
+
+### **With Notifications**
+Diagnostics complement [Discord Webhooks](./conclusionwebhook.md) and [NTFY](./ntfy.md):
+- **Webhooks** โ Immediate error alerts
+- **Diagnostics** โ Visual evidence for investigation
+- **Combined** โ Complete error visibility
+
+### **With Development Workflow**
+```bash
+# 1. Run script with diagnostics
+npm start
+
+# 2. Check for captures after errors
+ls reports/$(date +%Y-%m-%d)/
+
+# 3. Analyze screenshots and HTML
+# Open .png files for visual state
+# Review .html files for DOM structure
+```
+
+---
+
+## ๐ Privacy & Security
+
+- **Local Only** โ All captures stored locally
+- **No Uploads** โ Zero external data transmission
+- **Account Info** โ May contain sensitive data
+- **Secure Storage** โ Use appropriate folder permissions
+- **Regular Cleanup** โ Recommended for sensitive environments
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Discord Webhooks](./conclusionwebhook.md)** โ Error notification alerts
+- **[NTFY Notifications](./ntfy.md)** โ Mobile push notifications
+- **[Security](./security.md)** โ Privacy and data protection
\ No newline at end of file
diff --git a/docs/docker.md b/docs/docker.md
new file mode 100644
index 0000000..8b81c30
--- /dev/null
+++ b/docs/docker.md
@@ -0,0 +1,85 @@
+# ๐ณ Docker Guide
+
+
+
+**โก Lightweight containerized deployment**
+*Automated Microsoft Rewards with minimal Docker footprint*
+
+
+
+---
+
+## ๐ Quick Start
+
+### **Prerequisites**
+- โ
`src/accounts.json` configured with your Microsoft accounts
+- โ
`src/config.json` exists (uses defaults if not customized)
+- โ
Docker & Docker Compose installed
+
+### **Launch**
+```bash
+# Build and start the container
+docker compose up -d
+
+# Monitor the automation
+docker logs -f microsoft-rewards-script
+
+# Stop when needed
+docker compose down
+```
+
+**That's it!** The container runs the built-in scheduler automatically.uide
+
+This project ships with a Docker setup tailored for headless runs. It uses Playwrightโs Chromium Headless Shell to keep the image small.
+
+## Quick Start
+- Ensure you have `src/accounts.json` and `src/config.json` in the repo
+- Build and start:
+ - `docker compose up -d`
+- Follow logs:
+ - `docker logs -f microsoft-rewards-script`
+
+## Volumes & Files
+The compose file mounts:
+- `./src/accounts.json` โ `/usr/src/microsoft-rewards-script/accounts.json` (readโonly)
+- `./src/config.json` โ `/usr/src/microsoft-rewards-script/config.json` (readโonly)
+- `./sessions` โ `/usr/src/microsoft-rewards-script/sessions` (persist login sessions)
+
+You can also use env overrides supported by the app loader:
+- `ACCOUNTS_FILE=/path/to/accounts.json`
+- `ACCOUNTS_JSON='[ {"email":"...","password":"..."} ]'`
+
+## Environment
+Useful variables:
+- `TZ` โ container time zone (e.g., `Europe/Paris`)
+- `NODE_ENV=production`
+- `FORCE_HEADLESS=1` โ ensures headless mode inside the container
+- Scheduler knobs (optional):
+ - `SCHEDULER_DAILY_JITTER_MINUTES_MIN` / `SCHEDULER_DAILY_JITTER_MINUTES_MAX`
+ - `SCHEDULER_PASS_TIMEOUT_MINUTES`
+ - `SCHEDULER_FORK_PER_PASS`
+
+## Headless Browsers
+The Docker image installs only Chromium Headless Shell via:
+- `npx playwright install --with-deps --only-shell`
+
+This dramatically reduces image size vs. installing all Playwright browsers.
+
+## Oneโshot vs. Scheduler
+- Default command runs the builtโin scheduler: `npm run start:schedule`
+- For oneโshot run, override the command:
+ - `docker run --rm ... node ./dist/index.js`
+
+## Tips
+- If you see 2FA prompts, add your TOTP Base32 secret to `accounts.json` so the bot can autoโfill codes.
+- Use a persistent `sessions` volume to avoid reโlogging every run.
+- For proxies per account, fill the `proxy` block in your `accounts.json` (see [Proxy](./proxy.md)).
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup before containerization
+- **[Accounts & 2FA](./accounts.md)** โ Configure accounts for Docker
+- **[Scheduler](./schedule.md)** โ Alternative to Docker cron automation
+- **[Proxy Configuration](./proxy.md)** โ Network routing in containers
\ No newline at end of file
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..0be2ab6
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,136 @@
+# ๐ Getting Started
+
+
+
+**๐ฏ From zero to earning Microsoft Rewards points in minutes**
+*Complete setup guide for beginners*
+
+
+
+---
+
+## โ
Requirements
+
+- **Node.js 18+** (22 recommended) โ [Download here](https://nodejs.org/)
+- **Microsoft accounts** with email + password
+- **Optional:** Docker for containerized deployment
+
+---
+
+## โก Quick Setup (Recommended)
+
+
+
+### **๐ฌ One Command, Total Automation**
+
+
+
+```bash
+# ๐ช Windows
+setup/setup.bat
+
+# ๐ง Linux/macOS/WSL
+bash setup/setup.sh
+
+# ๐ Any platform
+npm run setup
+```
+
+**That's it!** The wizard will:
+- โ
Help you create `src/accounts.json` with your Microsoft credentials
+- โ
Install all dependencies automatically
+- โ
Build the TypeScript project
+- โ
Start earning points immediately
+
+---
+
+## ๐ ๏ธ Manual Setup
+
+
+๐ Prefer step-by-step? Click here
+
+### 1๏ธโฃ **Configure Your Accounts**
+```bash
+cp src/accounts.example.json src/accounts.json
+# Edit accounts.json with your Microsoft credentials
+```
+
+### 2๏ธโฃ **Install Dependencies & Build**
+```bash
+npm install
+npm run build
+```
+
+### 3๏ธโฃ **Choose Your Mode**
+```bash
+# Single run (test it works)
+npm start
+
+# Automated daily scheduler (set and forget)
+npm run start:schedule
+```
+
+
+
+---
+
+## ๐ฏ What Happens Next?
+
+The script will automatically:
+- ๐ **Search Bing** for points (desktop + mobile)
+- ๐
**Complete daily sets** (quizzes, polls, activities)
+- ๐ **Grab promotions** and bonus opportunities
+- ๐ **Work on punch cards** (multi-day challenges)
+- โ
**Daily check-ins** for easy points
+- ๐ **Read articles** for additional rewards
+
+**All while looking completely natural to Microsoft!** ๐ค
+
+---
+
+## ๐ณ Docker Alternative
+
+If you prefer containers:
+
+```bash
+# Ensure accounts.json and config.json exist
+docker compose up -d
+
+# Follow logs
+docker logs -f microsoft-rewards-script
+```
+
+**[Full Docker Guide โ](./docker.md)**
+
+---
+
+## ๐ง Next Steps
+
+Once running, explore these guides:
+
+| Priority | Guide | Why Important |
+|----------|-------|---------------|
+| **High** | **[Accounts & 2FA](./accounts.md)** | Set up TOTP for secure automation |
+| **High** | **[Scheduling](./schedule.md)** | Configure automated daily runs |
+| **Medium** | **[Notifications](./ntfy.md)** | Get alerts on your phone |
+| **Low** | **[Humanization](./humanization.md)** | Advanced anti-detection |
+
+---
+
+## ๐ Need Help?
+
+**Script not starting?** โ [Troubleshooting Guide](./diagnostics.md)
+**Login issues?** โ [Accounts & 2FA Setup](./accounts.md)
+**Want Docker?** โ [Container Guide](./docker.md)
+
+**Found a bug?** [Report it here](https://github.com/TheNetsky/Microsoft-Rewards-Script/issues)
+**Need support?** [Join our Discord](https://discord.gg/KRBFxxsU)
+
+---
+
+## ๐ Related Guides
+
+- **[Accounts & 2FA](./accounts.md)** โ Add Microsoft accounts with TOTP
+- **[Docker](./docker.md)** โ Deploy with containers
+- **[Scheduler](./schedule.md)** โ Automate daily execution
+- **[Discord Webhooks](./conclusionwebhook.md)** โ Get run summaries
\ No newline at end of file
diff --git a/docs/humanization.md b/docs/humanization.md
new file mode 100644
index 0000000..0a5c675
--- /dev/null
+++ b/docs/humanization.md
@@ -0,0 +1,277 @@
+# ๐ค Humanization (Human Mode)
+
+
+
+**๐ญ Natural automation that mimics human behavior**
+*Subtle gestures for safer operation*
+
+
+
+---
+
+## ๐ฏ What is Humanization?
+
+Human Mode adds **subtle human-like behavior** to make your automation look and feel more natural. It's designed to be **safe by design** with minimal, realistic gestures.
+
+### **Key Features**
+- ๐ฒ **Random delays** โ Natural pause variation
+- ๐ฑ๏ธ **Micro movements** โ Subtle mouse gestures
+- ๐ **Tiny scrolls** โ Minor page adjustments
+- โฐ **Time windows** โ Run during specific hours
+- ๐
**Random off days** โ Skip days naturally
+- ๐ **Safe by design** โ Never clicks random elements
+
+---
+
+## โ๏ธ Configuration
+
+### **Simple Setup (Recommended)**
+```json
+{
+ "humanization": {
+ "enabled": true
+ }
+}
+```
+
+### **Advanced Configuration**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "actionDelay": { "min": 150, "max": 450 },
+ "gestureMoveProb": 0.4,
+ "gestureScrollProb": 0.2,
+ "allowedWindows": ["08:00-10:30", "20:00-22:30"],
+ "randomOffDaysPerWeek": 1
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `true` | Master toggle for all humanization |
+| `actionDelay` | `{min: 150, max: 450}` | Random pause between actions (ms) |
+| `gestureMoveProb` | `0.4` | Probability (0-1) for tiny mouse moves |
+| `gestureScrollProb` | `0.2` | Probability (0-1) for minor scrolls |
+| `allowedWindows` | `[]` | Time windows for script execution |
+| `randomOffDaysPerWeek` | `1` | Skip N random days per week |
+
+---
+
+## ๐ญ How It Works
+
+### **Action Delays**
+- **Random pauses** between automation steps
+- **Natural variation** mimics human decision time
+- **Configurable range** allows fine-tuning
+
+### **Gesture Simulation**
+- **Micro mouse moves** โ Tiny cursor adjustments (safe zones only)
+- **Minor scrolls** โ Small page movements (non-interactive areas)
+- **Probability-based** โ Not every action includes gestures
+
+### **Temporal Patterns**
+- **Time windows** โ Only run during specified hours
+- **Random off days** โ Skip days to avoid rigid patterns
+- **Natural scheduling** โ Mimics human usage patterns
+
+---
+
+## ๐ฏ Usage Examples
+
+### **Default Setup (Recommended)**
+```json
+{
+ "humanization": { "enabled": true }
+}
+```
+โ
**Best for most users** โ Balanced safety and naturalness
+
+### **Minimal Humanization**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "gestureMoveProb": 0.1,
+ "gestureScrollProb": 0.1,
+ "actionDelay": { "min": 100, "max": 200 }
+ }
+}
+```
+โก **Faster execution** with minimal gestures
+
+### **Maximum Natural Behavior**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "actionDelay": { "min": 300, "max": 800 },
+ "gestureMoveProb": 0.6,
+ "gestureScrollProb": 0.4,
+ "allowedWindows": ["08:30-11:00", "19:00-22:00"],
+ "randomOffDaysPerWeek": 2
+ }
+}
+```
+๐ญ **Most human-like** but slower execution
+
+### **Disabled Humanization**
+```json
+{
+ "humanization": { "enabled": false }
+}
+```
+๐ **Fastest execution** โ automation optimized
+
+---
+
+## โฐ Time Windows
+
+### **Setup**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "allowedWindows": ["08:00-10:30", "20:00-22:30"]
+ }
+}
+```
+
+### **Behavior**
+- Script **waits** until next allowed window
+- Uses **local time** for scheduling
+- **Multiple windows** supported per day
+- **Empty array** `[]` = no time restrictions
+
+### **Examples**
+```json
+// Morning and evening windows
+"allowedWindows": ["08:00-10:30", "20:00-22:30"]
+
+// Lunch break only
+"allowedWindows": ["12:00-13:00"]
+
+// Extended evening window
+"allowedWindows": ["18:00-23:00"]
+
+// No restrictions
+"allowedWindows": []
+```
+
+---
+
+## ๐
Random Off Days
+
+### **Purpose**
+Mimics natural human behavior by skipping random days per week.
+
+### **Configuration**
+```json
+{
+ "humanization": {
+ "randomOffDaysPerWeek": 1 // Skip 1 random day per week
+ }
+}
+```
+
+### **Options**
+- `0` โ Never skip days
+- `1` โ Skip 1 random day per week (default)
+- `2` โ Skip 2 random days per week
+- `3+` โ Higher values for more irregular patterns
+
+---
+
+## ๐ Safety Features
+
+### **Safe by Design**
+- โ
**Never clicks** arbitrary elements
+- โ
**Gestures only** in safe zones
+- โ
**Minor movements** โ pixel-level adjustments
+- โ
**Probability-based** โ Natural randomness
+- โ
**Non-interactive areas** โ Avoids clickable elements
+
+### **Buy Mode Compatibility**
+- **Passive monitoring** remains unaffected
+- **No interference** with manual actions
+- **Background tasks** only for monitoring
+
+---
+
+## ๐ Performance Impact
+
+| Setting | Speed Impact | Natural Feel | Recommendation |
+|---------|--------------|--------------|----------------|
+| **Disabled** | Fastest | Robotic | Development only |
+| **Default** | Moderate | Balanced | **Recommended** |
+| **High probability** | Slower | Very natural | Conservative users |
+| **Time windows** | Delayed start | Realistic | Scheduled execution |
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **Script too slow** | Reduce `actionDelay` values; lower probabilities |
+| **Too robotic** | Increase probabilities; add time windows |
+| **Runs outside hours** | Check `allowedWindows` format (24-hour time) |
+| **Skipping too many days** | Reduce `randomOffDaysPerWeek` |
+| **Gestures interfering** | Lower probabilities or disable specific gestures |
+
+### **Debug Humanization**
+```powershell
+$env:DEBUG_HUMANIZATION=1; npm start
+```
+
+---
+
+## ๐๏ธ Presets
+
+### **Conservative**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "actionDelay": { "min": 200, "max": 600 },
+ "gestureMoveProb": 0.6,
+ "gestureScrollProb": 0.4,
+ "allowedWindows": ["08:00-10:00", "20:00-22:00"],
+ "randomOffDaysPerWeek": 2
+ }
+}
+```
+
+### **Balanced (Default)**
+```json
+{
+ "humanization": {
+ "enabled": true
+ }
+}
+```
+
+### **Performance**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "actionDelay": { "min": 100, "max": 250 },
+ "gestureMoveProb": 0.2,
+ "gestureScrollProb": 0.1,
+ "randomOffDaysPerWeek": 0
+ }
+}
+```
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Scheduler](./schedule.md)** โ Automated timing and execution
+- **[Security](./security.md)** โ Privacy and detection avoidance
+- **[Buy Mode](./buy-mode.md)** โ Manual purchasing with monitoring
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..5417ec5
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,96 @@
+# ๐ Microsoft Rewards Script V2 Documentation
+
+
+
+**๐ฏ Your complete guide to automating Microsoft Rewards**
+*Everything you need to get started and master the script*
+
+
+
+---
+
+## ๐ Quick Navigation
+
+### **Essential Setup**
+| Guide | Description |
+|-------|-------------|
+| **[๐ฌ Getting Started](./getting-started.md)** | Zero to running โ complete setup guide |
+| **[๐ค Accounts & 2FA](./accounts.md)** | Microsoft accounts + TOTP authentication |
+| **[๐ณ Docker](./docker.md)** | Container deployment with headless browsers |
+
+### **Operations & Advanced**
+| Guide | Description |
+|-------|-------------|
+| **[โฐ Scheduling](./schedule.md)** | Automated daily runs and timing |
+| **[๐ ๏ธ Diagnostics](./diagnostics.md)** | Troubleshooting and error capture |
+| **[๐ง Humanization](./humanization.md)** | Anti-detection and natural behavior |
+| **[๐ Proxy Setup](./proxy.md)** | Network routing and IP management |
+| **[โ๏ธ Configuration Reference](./config.md)** | Full `config.json` field documentation |
+
+### **Notifications & Monitoring**
+| Guide | Description |
+|-------|-------------|
+| **[๐ฑ NTFY Push](./ntfy.md)** | Mobile push notifications |
+| **[๐ Discord Webhooks](./conclusionwebhook.md)** | Rich server notifications |
+
+### **Special Modes**
+| Guide | Description |
+|-------|-------------|
+| **[๐ณ Buy Mode](./buy-mode.md)** | Manual redemption with live monitoring |
+
+---
+
+## ๐ฏ Recommended Reading Path
+
+**New Users:** Getting Started โ Accounts & 2FA โ Choose Docker OR Scheduling
+**Advanced Users:** Humanization โ Diagnostics โ Notifications
+**Docker Users:** Getting Started โ Accounts & 2FA โ Docker โ NTFY/Webhookstion Index
+
+Welcome to the Microsoft Rewards Script V2 docs. Start here:
+
+- Getting Started: highโlevel setup from zero to running โ [Getting Started](./getting-started.md)
+- Accounts & Authentication โ [Accounts & TOTP (2FA)](./accounts.md)
+- Runtime & Operations โ [Docker Guide](./docker.md), [Scheduling](./schedule.md), [Diagnostics](./diagnostics.md), [Humanization](./humanization.md), [Job State](./jobstate.md), [Auto Update](./update.md), [Security](./security.md)
+- Notifications โ [NTFY Push](./ntfy.md), [Conclusion Webhook (Discord)](./conclusionwebhook.md)
+- Modes & Activities โ [Buy Mode](./buy-mode.md)
+
+Recommended reading order if youโre new: Getting Started โ Accounts & TOTP โ Docker or Scheduler.# Documentation Index
+
+Welcome to the Microsoft Rewards Script V2 documentation. Start here to set up your environment, add your Microsoft accounts, and understand how the bot operates.
+
+- Getting Started: Highโlevel setup from zero to running
+ - [Getting Started](./getting-started.md)
+- Accounts & Authentication
+ - [Accounts & TOTP (2FA)](./accounts.md)
+ - [Proxy Setup](./proxy.md)
+- Runtime & Operations
+ - [Docker Guide](./docker.md)
+ - [Scheduling](./schedule.md)
+ - [Diagnostics](./diagnostics.md)
+ - [Humanization](./humanization.md)
+ - [Job State](./jobstate.md)
+ - [Auto Update](./update.md)
+ - [Security Notes](./security.md)
+- Notifications
+ - [NTFY Push](./ntfy.md)
+ - [Conclusion Webhook (Discord)](./conclusionwebhook.md)
+- Modes & Activities
+ - [Buy Mode](./buy-mode.md)
+
+If you are new, read Getting Started first, then Accounts & TOTP.
+
+---
+
+## ๐ Quick Start Path
+
+**New users should follow this sequence:**
+
+1. **[Getting Started](./getting-started.md)** โ Install and basic configuration
+2. **[Accounts & 2FA](./accounts.md)** โ Add your Microsoft accounts
+3. **[Docker](./docker.md)** OR **[Scheduler](./schedule.md)** โ Choose deployment method
+4. **[NTFY](./ntfy.md)** OR **[Discord Webhooks](./conclusionwebhook.md)** โ Set up notifications
+
+**Advanced users may also need:**
+- **[Proxy](./proxy.md)** โ For privacy and geographic routing
+- **[Security](./security.md)** โ Account protection and incident response
+- **[Humanization](./humanization.md)** โ Natural behavior simulation
diff --git a/docs/jobstate.md b/docs/jobstate.md
new file mode 100644
index 0000000..8ea189d
--- /dev/null
+++ b/docs/jobstate.md
@@ -0,0 +1,339 @@
+# ๐พ Job State Persistence
+
+
+
+**๐ Resume interrupted tasks and track progress across runs**
+*Never lose your progress again*
+
+
+
+---
+
+## ๐ฏ What is Job State Persistence?
+
+Job state persistence allows the script to **resume interrupted tasks** and **track progress** across multiple runs, ensuring no work is lost when the script is stopped or crashes.
+
+### **Key Features**
+- ๐ **Resumable tasks** โ Pick up exactly where you left off
+- ๐
**Daily tracking** โ Date-specific progress monitoring
+- ๐ค **Per-account isolation** โ Independent progress for each account
+- ๐ก๏ธ **Corruption protection** โ Atomic writes prevent data loss
+- ๐ **Performance optimized** โ Minimal overhead
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "jobState": {
+ "enabled": true,
+ "dir": ""
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `enabled` | Enable job state persistence | `true` |
+| `dir` | Custom directory for state files | `""` (uses `sessions/job-state`) |
+
+---
+
+## ๐๏ธ How It Works
+
+### **State Tracking**
+- ๐ **Monitors completion** status of individual activities
+- ๐ **Tracks progress** for daily sets, searches, and promotional tasks
+- โ **Prevents duplicates** when script restarts
+
+### **Storage Structure**
+```
+sessions/job-state/
+โโโ account1@email.com/
+โ โโโ daily-set-2025-01-20.json
+โ โโโ desktop-search-2025-01-20.json
+โ โโโ mobile-search-2025-01-20.json
+โโโ account2@email.com/
+ โโโ daily-set-2025-01-20.json
+ โโโ promotional-tasks-2025-01-20.json
+```
+
+### **State File Format**
+```json
+{
+ "date": "2025-01-20",
+ "account": "user@email.com",
+ "type": "daily-set",
+ "completed": [
+ "daily-check-in",
+ "quiz-1",
+ "poll-1"
+ ],
+ "remaining": [
+ "quiz-2",
+ "search-desktop"
+ ],
+ "lastUpdate": "2025-01-20T10:30:00.000Z"
+}
+```
+
+---
+
+## ๐ Key Benefits
+
+### **Resumable Tasks**
+- โ
**Script restarts** pick up where they left off
+- โ
**Individual completion** is remembered
+- โ
**Avoid re-doing** completed activities
+
+### **Daily Reset**
+- ๐
**Date-specific** state files
+- ๐
**New day** automatically starts fresh tracking
+- ๐ **History preserved** for analysis
+
+### **Account Isolation**
+- ๐ค **Separate state** per account
+- โก **Parallel processing** doesn't interfere
+- ๐ **Independent progress** tracking
+
+---
+
+## ๐ Use Cases
+
+### **Interrupted Executions**
+| Scenario | Benefit |
+|----------|---------|
+| **Network issues** | Resume when connection restored |
+| **System reboots** | Continue after restart |
+| **Manual termination** | Pick up from last checkpoint |
+| **Resource exhaustion** | Recover without losing progress |
+
+### **Selective Reruns**
+| Feature | Description |
+|---------|-------------|
+| **Skip completed sets** | Avoid redoing finished daily activities |
+| **Resume searches** | Continue partial search sessions |
+| **Retry failed tasks** | Target only problematic activities |
+| **Account targeting** | Process specific accounts only |
+
+### **Progress Monitoring**
+- ๐ **Track completion rates** across accounts
+- ๐ **Identify problematic** activities
+- โฑ๏ธ **Monitor task duration** trends
+- ๐ **Debug stuck** or slow tasks
+
+---
+
+## ๐ ๏ธ Technical Implementation
+
+### **Checkpoint Strategy**
+- ๐พ **State saved** after each completed activity
+- โ๏ธ **Atomic writes** prevent corruption
+- ๐ **Lock-free design** for concurrent access
+
+### **Performance Optimization**
+- โก **Minimal I/O overhead** โ Fast state updates
+- ๐ง **In-memory caching** โ Reduce disk access
+- ๐ฅ **Lazy loading** โ Load state files on demand
+
+### **Error Handling**
+- ๐ง **Corrupted files** are rebuilt automatically
+- ๐ **Missing directories** created as needed
+- ๐ฏ **Graceful degradation** when disabled
+
+---
+
+## ๐๏ธ File Management
+
+### **Automatic Behavior**
+- ๐
**Date-specific files** โ New files for each day
+- ๐พ **Preserved history** โ Old files remain for reference
+- ๐ **No auto-deletion** โ Manual cleanup recommended
+
+### **Manual Maintenance**
+```powershell
+# Clean state files older than 7 days
+Get-ChildItem sessions/job-state -Recurse -Filter "*.json" | Where-Object {$_.LastWriteTime -lt (Get-Date).AddDays(-7)} | Remove-Item
+
+# Reset all job state (start fresh)
+Remove-Item -Recurse -Force sessions/job-state/
+
+# Reset specific account state
+Remove-Item -Recurse -Force sessions/job-state/user@email.com/
+```
+
+---
+
+## ๐ Example Workflows
+
+### **Interrupted Daily Run**
+```
+Day 1 - 10:30 AM:
+โ
Account A: Daily set completed
+๐ Account B: 3/5 daily tasks done
+โ Script crashes
+
+Day 1 - 2:00 PM:
+๐ Script restarts
+โ
Account A: Skipped (already complete)
+๐ Account B: Resumes with 2 remaining tasks
+```
+
+### **Multi-Day Tracking**
+```
+Monday:
+๐
daily-set-2025-01-20.json created
+โ
All tasks completed
+
+Tuesday:
+๐
daily-set-2025-01-21.json created
+๐ Fresh start for new day
+๐ Monday's progress preserved
+```
+
+---
+
+## ๐ Debugging Job State
+
+### **State Inspection**
+```powershell
+# View current state for account
+Get-Content sessions/job-state/user@email.com/daily-set-2025-01-20.json | ConvertFrom-Json
+
+# List all state files
+Get-ChildItem sessions/job-state -Recurse -Filter "*.json"
+```
+
+### **Debug Output**
+Enable verbose logging to see state operations:
+```powershell
+$env:DEBUG_REWARDS_VERBOSE=1; npm start
+```
+
+Sample output:
+```
+[INFO] Loading job state for user@email.com (daily-set)
+[INFO] Found 3 completed tasks, 2 remaining
+[INFO] Skipping completed task: daily-check-in
+[INFO] Starting task: quiz-2
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Cause | Solution |
+|---------|-------|----------|
+| **Tasks not resuming** | Missing/corrupt state files | Check file permissions; verify directory exists |
+| **Duplicate execution** | Clock sync issues | Ensure system time is accurate |
+| **Excessive files** | No cleanup schedule | Implement regular state file cleanup |
+| **Permission errors** | Write access denied | Verify sessions/ directory is writable |
+
+### **Common Issues**
+
+#### **Tasks Not Resuming**
+```
+[ERROR] Failed to load job state: Permission denied
+```
+**Solutions:**
+- โ
Check file/directory permissions
+- โ
Verify state directory exists
+- โ
Ensure write access to sessions/
+
+#### **Duplicate Task Execution**
+```
+[WARN] Task appears to be running twice
+```
+**Solutions:**
+- โ
Check for corrupt state files
+- โ
Verify system clock synchronization
+- โ
Clear state for affected account
+
+#### **Storage Growth**
+```
+[INFO] Job state directory: 2.3GB (1,247 files)
+```
+**Solutions:**
+- โ
Implement regular cleanup schedule
+- โ
Remove old state files (7+ days)
+- โ
Monitor disk space usage
+
+---
+
+## ๐ค Integration Features
+
+### **Session Persistence**
+- ๐ช **Works alongside** browser session storage
+- ๐ **Complements** cookie and fingerprint persistence
+- ๐ **Independent of** proxy and authentication state
+
+### **Clustering**
+- โก **Isolated state** per cluster worker
+- ๐ซ **No shared state** between parallel processes
+- ๐ **Worker-specific** directories
+
+### **Scheduling**
+- โฐ **Persists across** scheduled runs
+- ๐
**Daily reset** at midnight automatically
+- ๐ **Long-running continuity** maintained
+
+---
+
+## โ๏ธ Advanced Configuration
+
+### **Custom State Directory**
+```json
+{
+ "jobState": {
+ "enabled": true,
+ "dir": "/custom/path/to/state"
+ }
+}
+```
+
+### **Disabling Job State**
+```json
+{
+ "jobState": {
+ "enabled": false
+ }
+}
+```
+
+**Effects when disabled:**
+- โ **Tasks restart** from beginning each run
+- โ **No progress tracking** between sessions
+- โ **Potential duplicate work** on interruptions
+- โ
**Slightly faster startup** (no state loading)
+
+---
+
+## ๐ Best Practices
+
+### **Development**
+- โ
**Enable for testing** โ Consistent behavior
+- ๐งน **Clear between changes** โ Fresh state for major updates
+- ๐ **Monitor for debugging** โ State files reveal execution flow
+
+### **Production**
+- โ
**Always enabled** โ Reliability is critical
+- ๐พ **Regular backups** โ State directory backups
+- ๐ **Monitor disk usage** โ Prevent storage growth
+
+### **Maintenance**
+- ๐๏ธ **Weekly cleanup** โ Remove old state files
+- ๐ **Health checks** โ Verify state integrity
+- ๐ **Usage monitoring** โ Track storage trends
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Scheduler](./schedule.md)** โ Automated timing and execution
+- **[Diagnostics](./diagnostics.md)** โ Error capture and debugging
+- **[Security](./security.md)** โ Privacy and data protection
\ No newline at end of file
diff --git a/docs/ntfy.md b/docs/ntfy.md
new file mode 100644
index 0000000..bbd3f9e
--- /dev/null
+++ b/docs/ntfy.md
@@ -0,0 +1,407 @@
+# ๐ฑ NTFY Push Notifications
+
+
+
+**๐ Real-time push notifications to your devices**
+*Stay informed wherever you are*
+
+
+
+---
+
+## ๐ฏ What is NTFY?
+
+NTFY is a **simple HTTP-based pub-sub notification service** that sends push notifications to your phone, desktop, or web browser. Perfect for real-time alerts about script events and errors.
+
+### **Key Features**
+- ๐ฑ **Mobile & Desktop** โ Push to any device
+- ๐ **Free & Open Source** โ No vendor lock-in
+- ๐ **Self-hostable** โ Complete privacy control
+- โก **Real-time delivery** โ Instant notifications
+- ๐ **Authentication support** โ Secure topics
+
+### **Official Links**
+- **Website** โ [ntfy.sh](https://ntfy.sh)
+- **Documentation** โ [docs.ntfy.sh](https://docs.ntfy.sh)
+- **GitHub** โ [binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "notifications": {
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "rewards-script",
+ "authToken": ""
+ }
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Description | Example |
+|---------|-------------|---------|
+| `enabled` | Enable NTFY notifications | `true` |
+| `url` | NTFY server URL | `"https://ntfy.sh"` |
+| `topic` | Notification topic name | `"rewards-script"` |
+| `authToken` | Authentication token (optional) | `"tk_abc123..."` |
+
+---
+
+## ๐ Setup Options
+
+### **Option 1: Public Service (Easiest)**
+```json
+{
+ "notifications": {
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "your-unique-topic-name"
+ }
+ }
+}
+```
+
+**Pros:**
+- โ
No server setup required
+- โ
Always available
+- โ
Free to use
+
+**Cons:**
+- โ Public server (less privacy)
+- โ Rate limits apply
+- โ Dependent on external service
+
+### **Option 2: Self-Hosted (Recommended)**
+```json
+{
+ "notifications": {
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.yourdomain.com",
+ "topic": "rewards",
+ "authToken": "tk_your_token_here"
+ }
+ }
+}
+```
+
+**Self-Hosted Setup:**
+```yaml
+# docker-compose.yml
+version: '3.8'
+services:
+ ntfy:
+ image: binwiederhier/ntfy
+ container_name: ntfy
+ ports:
+ - "80:80"
+ volumes:
+ - ./data:/var/lib/ntfy
+ command: serve
+```
+
+---
+
+## ๐ Authentication
+
+### **When You Need Auth**
+Authentication tokens are **optional** but required for:
+- ๐ **Private topics** with username/password
+- ๐ **Private NTFY servers** with authentication
+- ๐ก๏ธ **Preventing spam** on your topic
+
+### **Getting an Auth Token**
+
+#### **Method 1: Command Line**
+```bash
+ntfy token
+```
+
+#### **Method 2: Web Interface**
+1. Visit your NTFY server (e.g., `https://ntfy.sh`)
+2. Go to **Account** section
+3. Generate **new access token**
+
+#### **Method 3: API**
+```bash
+curl -X POST -d '{"label":"rewards-script"}' \
+ -H "Authorization: Bearer YOUR_LOGIN_TOKEN" \
+ https://ntfy.sh/v1/account/tokens
+```
+
+### **Token Format**
+- Tokens start with `tk_` (e.g., `tk_abc123def456...`)
+- Use Bearer authentication format
+- Tokens are permanent until revoked
+
+---
+
+## ๐ฒ Receiving Notifications
+
+### **Mobile Apps**
+- **Android** โ [NTFY on Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
+- **iOS** โ [NTFY on App Store](https://apps.apple.com/app/ntfy/id1625396347)
+- **F-Droid** โ Available for Android
+
+### **Desktop Options**
+- **Web Interface** โ Visit your NTFY server URL
+- **Desktop Apps** โ Available for Linux, macOS, Windows
+- **Browser Extension** โ Chrome/Firefox extensions
+
+### **Setup Steps**
+1. **Install** NTFY app on your device
+2. **Add subscription** to your topic name
+3. **Enter server URL** (if self-hosted)
+4. **Test** with a manual message
+
+---
+
+## ๐ Notification Types
+
+### **Error Notifications**
+**Priority:** Max ๐จ | **Trigger:** Script errors and failures
+```
+[ERROR] DESKTOP [LOGIN] Failed to login: Invalid credentials
+```
+
+### **Warning Notifications**
+**Priority:** High โ ๏ธ | **Trigger:** Important warnings
+```
+[WARN] MOBILE [SEARCH] Didn't gain expected points from search
+```
+
+### **Info Notifications**
+**Priority:** Default ๐ | **Trigger:** Important milestones
+```
+[INFO] MAIN [TASK] Started tasks for account user@email.com
+```
+
+### **Buy Mode Notifications**
+**Priority:** High ๐ณ | **Trigger:** Point spending detected
+```
+๐ณ Spend detected (Buy Mode)
+Account: user@email.com
+Spent: -500 points
+Current: 12,500 points
+Session spent: 1,200 points
+```
+
+### **Conclusion Summary**
+**End-of-run summary with rich formatting:**
+```
+๐ฏ Microsoft Rewards Summary
+Accounts: 3 โข 0 with issues
+Total: 15,230 -> 16,890 (+1,660)
+Average Duration: 8m 32s
+Cumulative Runtime: 25m 36s
+```
+
+---
+
+## ๐ค Integration with Discord
+
+### **Complementary Setup**
+Use **both** NTFY and Discord for comprehensive monitoring:
+
+```json
+{
+ "notifications": {
+ "webhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/..."
+ },
+ "conclusionWebhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/..."
+ },
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "rewards-script"
+ }
+ }
+}
+```
+
+### **Coverage Comparison**
+
+| Feature | NTFY | Discord |
+|---------|------|---------|
+| **Mobile push** | โ
Instant | โ App required |
+| **Rich formatting** | โ Text only | โ
Embeds + colors |
+| **Desktop alerts** | โ
Native | โ
App notifications |
+| **Offline delivery** | โ
Queued | โ Real-time only |
+| **Self-hosted** | โ
Easy | โ Complex |
+
+---
+
+## ๐๏ธ Advanced Configuration
+
+### **Custom Topic Names**
+Use descriptive, unique topic names:
+```json
+{
+ "topic": "rewards-production-server1"
+}
+{
+ "topic": "msn-rewards-home-pc"
+}
+{
+ "topic": "rewards-dev-testing"
+}
+```
+
+### **Environment-Specific**
+```json
+{
+ "notifications": {
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.internal.lan",
+ "topic": "homelab-rewards",
+ "authToken": "tk_homelab_token"
+ }
+ }
+}
+```
+
+---
+
+## ๐งช Testing & Debugging
+
+### **Manual Test Message**
+```bash
+# Public server (no auth)
+curl -d "Test message from rewards script" https://ntfy.sh/your-topic
+
+# With authentication
+curl -H "Authorization: Bearer tk_your_token" \
+ -d "Authenticated test message" \
+ https://ntfy.sh/your-topic
+```
+
+### **Script Debug Mode**
+```powershell
+$env:DEBUG_REWARDS_VERBOSE=1; npm start
+```
+
+### **Server Health Check**
+```bash
+# Check NTFY server status
+curl -s https://ntfy.sh/v1/health
+
+# List your topics (with auth)
+curl -H "Authorization: Bearer tk_your_token" \
+ https://ntfy.sh/v1/account/topics
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **No notifications** | Check topic spelling; verify app subscription |
+| **Auth failures** | Verify token format (`tk_`); check token validity |
+| **Wrong server** | Test server URL in browser; check HTTPS/HTTP |
+| **Rate limits** | Switch to self-hosted; reduce notification frequency |
+
+### **Common Fixes**
+- โ
**Topic name** โ Must match exactly between config and app
+- โ
**Server URL** โ Include `https://` and check accessibility
+- โ
**Token format** โ Must start with `tk_` for authentication
+- โ
**Network** โ Verify firewall/proxy settings
+
+---
+
+## ๐ Homelab Integration
+
+### **Official Support**
+NTFY is included in:
+- **Debian Trixie** (testing)
+- **Ubuntu** (latest versions)
+
+### **Popular Integrations**
+- **Sonarr/Radarr** โ Download completion notifications
+- **Prometheus** โ Alert manager integration
+- **Home Assistant** โ Automation notifications
+- **Portainer** โ Container status alerts
+
+### **Docker Stack Example**
+```yaml
+version: '3.8'
+services:
+ ntfy:
+ image: binwiederhier/ntfy
+ container_name: ntfy
+ ports:
+ - "80:80"
+ volumes:
+ - ./ntfy-data:/var/lib/ntfy
+ environment:
+ - NTFY_BASE_URL=https://ntfy.yourdomain.com
+ command: serve
+
+ rewards:
+ build: .
+ depends_on:
+ - ntfy
+ environment:
+ - NTFY_URL=http://ntfy:80
+```
+
+---
+
+## ๐ Privacy & Security
+
+### **Public Server (ntfy.sh)**
+- โ ๏ธ Messages pass through public infrastructure
+- โ ๏ธ Topic names visible in logs
+- โ
Suitable for non-sensitive notifications
+
+### **Self-Hosted Server**
+- โ
Complete control over data
+- โ
Private network deployment possible
+- โ
Recommended for sensitive information
+
+### **Best Practices**
+- ๐ Use **unique, non-guessable** topic names
+- ๐ Enable **authentication** for sensitive notifications
+- ๐ Use **self-hosted server** for maximum privacy
+- ๐ **Regularly rotate** authentication tokens
+
+### **Data Retention**
+- ๐จ Messages are **not permanently stored**
+- โฑ๏ธ Delivery attempts **retried** for short periods
+- ๐๏ธ **No long-term** message history
+
+---
+
+## โก Performance Impact
+
+### **Script Performance**
+- โ
**Minimal overhead** โ Fire-and-forget notifications
+- โ
**Non-blocking** โ Failed notifications don't affect script
+- โ
**Asynchronous** โ No execution delays
+
+### **Network Usage**
+- ๐ **Low bandwidth** โ Text-only messages
+- โก **HTTP POST** โ Simple, efficient protocol
+- ๐ **Retry logic** โ Automatic failure recovery
+
+---
+
+## ๐ Related Guides
+
+- **[Discord Webhooks](./conclusionwebhook.md)** โ Rich notification embeds
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Buy Mode](./buy-mode.md)** โ Manual purchasing notifications
+- **[Security](./security.md)** โ Privacy and data protection
\ No newline at end of file
diff --git a/docs/proxy.md b/docs/proxy.md
new file mode 100644
index 0000000..706992f
--- /dev/null
+++ b/docs/proxy.md
@@ -0,0 +1,611 @@
+# ๐ Proxy Configuration
+
+
+
+**๐ Route traffic through proxy servers for privacy and flexibility**
+*Enhanced anonymity and geographic control*
+
+
+
+---
+
+## ๐ฏ What Are Proxies?
+
+Proxies act as **intermediaries** between your script and Microsoft's servers, providing enhanced privacy, geographic flexibility, and network management capabilities.
+
+### **Key Benefits**
+- ๐ญ **IP masking** โ Hide your real IP address
+- ๐ **Geographic flexibility** โ Appear to browse from different locations
+- โก **Rate limiting** โ Distribute requests across multiple IPs
+- ๐ง **Network control** โ Route traffic through specific servers
+- ๐ **Privacy enhancement** โ Add layer of anonymity
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": false,
+ "server": "proxy.example.com:8080",
+ "username": "",
+ "password": "",
+ "bypass": []
+ }
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Description | Example |
+|---------|-------------|---------|
+| `enabled` | Enable proxy usage | `true` |
+| `server` | Proxy server address and port | `"proxy.example.com:8080"` |
+| `username` | Proxy authentication username | `"proxyuser"` |
+| `password` | Proxy authentication password | `"proxypass123"` |
+| `bypass` | Domains to bypass proxy | `["localhost", "*.internal.com"]` |
+
+---
+
+## ๐ Supported Proxy Types
+
+### **HTTP Proxies**
+**Most common type for web traffic**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "http://proxy.example.com:8080",
+ "username": "user",
+ "password": "pass"
+ }
+ }
+}
+```
+
+### **HTTPS Proxies**
+**Encrypted proxy connections**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "https://secure-proxy.example.com:8080",
+ "username": "user",
+ "password": "pass"
+ }
+ }
+}
+```
+
+### **SOCKS Proxies**
+**Support for SOCKS4 and SOCKS5**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "socks5://socks-proxy.example.com:1080",
+ "username": "user",
+ "password": "pass"
+ }
+ }
+}
+```
+
+---
+
+## ๐ข Popular Proxy Providers
+
+### **Residential Proxies (Recommended)**
+**High-quality IPs from real devices**
+
+#### **Top Providers**
+- **Bright Data** (formerly Luminati) โ Premium quality
+- **Smartproxy** โ User-friendly dashboard
+- **Oxylabs** โ Enterprise-grade
+- **ProxyMesh** โ Developer-focused
+
+#### **Configuration Example**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "rotating-residential.brightdata.com:22225",
+ "username": "customer-username-session-random",
+ "password": "your-password"
+ }
+ }
+}
+```
+
+### **Datacenter Proxies**
+**Fast and affordable server-based IPs**
+
+#### **Popular Providers**
+- **SquidProxies** โ Reliable performance
+- **MyPrivateProxy** โ Dedicated IPs
+- **ProxyRack** โ Budget-friendly
+- **Storm Proxies** โ Rotating options
+
+#### **Configuration Example**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "datacenter.squidproxies.com:8080",
+ "username": "username",
+ "password": "password"
+ }
+ }
+}
+```
+
+### **Free Proxies**
+**โ ๏ธ Not recommended for production use**
+
+#### **Risks**
+- โ Unreliable connections
+- โ Potential security issues
+- โ Often blocked by services
+- โ Poor performance
+
+---
+
+## ๐ Authentication Methods
+
+### **Username/Password (Most Common)**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "username": "your-username",
+ "password": "your-password"
+ }
+ }
+}
+```
+
+### **IP Whitelisting**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "username": "",
+ "password": ""
+ }
+ }
+}
+```
+
+**Setup Steps:**
+1. Contact proxy provider
+2. Provide your server's IP address
+3. Configure whitelist in provider dashboard
+4. Remove credentials from config
+
+### **Session-Based Authentication**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "session-proxy.example.com:8080",
+ "username": "customer-session-sticky123",
+ "password": "your-password"
+ }
+ }
+}
+```
+
+---
+
+## ๐ซ Bypass Configuration
+
+### **Local Development**
+**Bypass proxy for local services**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "bypass": [
+ "localhost",
+ "127.0.0.1",
+ "*.local",
+ "*.internal"
+ ]
+ }
+ }
+}
+```
+
+### **Specific Domains**
+**Route certain domains directly**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "bypass": [
+ "*.microsoft.com",
+ "login.live.com",
+ "account.microsoft.com"
+ ]
+ }
+ }
+}
+```
+
+### **Advanced Patterns**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "bypass": [
+ "*.intranet.*",
+ "192.168.*.*",
+ "10.*.*.*",
+ ""
+ ]
+ }
+ }
+}
+```
+
+---
+
+## ๐๏ธ Advanced Configurations
+
+### **Per-Account Proxies**
+**Different proxies for different accounts**
+```json
+{
+ "accounts": [
+ {
+ "email": "user1@example.com",
+ "password": "password1",
+ "proxy": {
+ "enabled": true,
+ "server": "proxy1.example.com:8080"
+ }
+ },
+ {
+ "email": "user2@example.com",
+ "password": "password2",
+ "proxy": {
+ "enabled": true,
+ "server": "proxy2.example.com:8080"
+ }
+ }
+ ]
+}
+```
+
+### **Failover Configuration**
+**Multiple proxy servers for redundancy**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "servers": [
+ "primary-proxy.example.com:8080",
+ "backup-proxy.example.com:8080",
+ "emergency-proxy.example.com:8080"
+ ],
+ "username": "user",
+ "password": "pass"
+ }
+ }
+}
+```
+
+### **Geographic Routing**
+**Location-specific proxy selection**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "regions": {
+ "us": "us-proxy.example.com:8080",
+ "eu": "eu-proxy.example.com:8080",
+ "asia": "asia-proxy.example.com:8080"
+ },
+ "defaultRegion": "us"
+ }
+ }
+}
+```
+
+---
+
+## ๐ Security & Environment Variables
+
+### **Credential Protection**
+**Secure proxy authentication**
+
+**Environment Variables:**
+```powershell
+# Set in environment
+$env:PROXY_USERNAME="your-username"
+$env:PROXY_PASSWORD="your-password"
+```
+
+**Configuration:**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "username": "${PROXY_USERNAME}",
+ "password": "${PROXY_PASSWORD}"
+ }
+ }
+}
+```
+
+### **HTTPS Verification**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "verifySSL": true,
+ "rejectUnauthorized": true
+ }
+ }
+}
+```
+
+### **Connection Encryption**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "https://encrypted-proxy.example.com:8080",
+ "tls": {
+ "enabled": true,
+ "version": "TLSv1.3"
+ }
+ }
+ }
+}
+```
+
+---
+
+## ๐งช Testing & Debugging
+
+### **Manual Tests**
+```bash
+# Test proxy connection
+curl --proxy proxy.example.com:8080 http://httpbin.org/ip
+
+# Test with authentication
+curl --proxy user:pass@proxy.example.com:8080 http://httpbin.org/ip
+
+# Test geolocation
+curl --proxy proxy.example.com:8080 http://ipinfo.io/json
+```
+
+### **Script Debug Mode**
+```powershell
+$env:DEBUG_PROXY=1; npm start
+```
+
+### **Health Check Script**
+```bash
+#!/bin/bash
+PROXY="proxy.example.com:8080"
+curl --proxy $PROXY --connect-timeout 10 http://httpbin.org/status/200
+echo "Proxy health: $?"
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Error | Solution |
+|---------|-------|----------|
+| **Connection Failed** | `ECONNREFUSED` | Verify server address/port; check firewall |
+| **Auth Failed** | `407 Proxy Authentication Required` | Verify username/password; check IP whitelist |
+| **Timeout** | `Request timeout` | Increase timeout values; try different server |
+| **SSL Error** | `certificate verify failed` | Disable SSL verification; update certificates |
+
+### **Common Error Messages**
+
+#### **Connection Issues**
+```
+[ERROR] Proxy connection failed: ECONNREFUSED
+```
+**Solutions:**
+- โ
Verify proxy server address and port
+- โ
Check proxy server is running
+- โ
Confirm firewall allows connections
+- โ
Test with different proxy server
+
+#### **Authentication Issues**
+```
+[ERROR] Proxy authentication failed: 407 Proxy Authentication Required
+```
+**Solutions:**
+- โ
Verify username and password
+- โ
Check account is active with provider
+- โ
Confirm IP is whitelisted (if applicable)
+- โ
Try different authentication method
+
+#### **Performance Issues**
+```
+[ERROR] Proxy timeout: Request timeout
+```
+**Solutions:**
+- โ
Increase timeout values
+- โ
Check proxy server performance
+- โ
Try different proxy server
+- โ
Reduce concurrent connections
+
+---
+
+## โก Performance Optimization
+
+### **Connection Settings**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "timeouts": {
+ "connect": 30000,
+ "request": 60000,
+ "idle": 120000
+ },
+ "connectionPooling": true,
+ "maxConnections": 10
+ }
+ }
+}
+```
+
+### **Compression Settings**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "compression": true,
+ "gzip": true
+ }
+ }
+}
+```
+
+### **Monitoring Metrics**
+- **Connection Success Rate** โ % of successful proxy connections
+- **Response Time** โ Average request latency through proxy
+- **Bandwidth Usage** โ Data transferred through proxy
+- **Error Rate** โ % of failed requests via proxy
+
+---
+
+## ๐ณ Container Integration
+
+### **Docker Environment**
+```dockerfile
+# Dockerfile
+ENV PROXY_ENABLED=true
+ENV PROXY_SERVER=proxy.example.com:8080
+ENV PROXY_USERNAME=user
+ENV PROXY_PASSWORD=pass
+```
+
+### **Kubernetes ConfigMap**
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: rewards-proxy-config
+data:
+ proxy.json: |
+ {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "username": "user",
+ "password": "pass"
+ }
+```
+
+### **Environment-Specific**
+```json
+{
+ "development": {
+ "proxy": { "enabled": false }
+ },
+ "staging": {
+ "proxy": {
+ "enabled": true,
+ "server": "staging-proxy.example.com:8080"
+ }
+ },
+ "production": {
+ "proxy": {
+ "enabled": true,
+ "server": "prod-proxy.example.com:8080"
+ }
+ }
+}
+```
+
+---
+
+## ๐ Best Practices
+
+### **Proxy Selection**
+- ๐ **Residential > Datacenter** โ Better for avoiding detection
+- ๐ฐ **Paid > Free** โ Reliability and security
+- ๐ **Multiple providers** โ Redundancy and failover
+- ๐ **Geographic diversity** โ Flexibility and compliance
+
+### **Configuration Management**
+- ๐ **Environment variables** โ Secure credential storage
+- ๐งช **Test before deploy** โ Verify configuration works
+- ๐ **Monitor performance** โ Track availability and speed
+- ๐ **Backup configs** โ Ready failover options
+
+### **Security Guidelines**
+- ๐ **HTTPS proxies** โ Encrypted connections when possible
+- ๐ก๏ธ **SSL verification** โ Verify certificates
+- ๐ **Rotate credentials** โ Regular password updates
+- ๐๏ธ **Monitor access** โ Watch for unauthorized usage
+
+---
+
+## โ๏ธ Legal & Compliance
+
+### **Terms of Service**
+- ๐ Review Microsoft's Terms of Service
+- ๐ Understand proxy provider's acceptable use policy
+- ๐ Ensure compliance with local regulations
+- ๐บ๏ธ Consider geographic restrictions
+
+### **Data Privacy**
+- ๐ Understand data flow through proxy
+- ๐ Review proxy provider's data retention policies
+- ๐ Implement additional encryption if needed
+- ๐ Monitor proxy logs and access
+
+### **Rate Limiting**
+- โฑ๏ธ Respect Microsoft's rate limits
+- โธ๏ธ Implement proper delays between requests
+- ๐ฆ Monitor for IP blocking or throttling
+- ๐ Use proxy rotation to distribute load
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Security](./security.md)** โ Privacy and data protection
+- **[Docker](./docker.md)** โ Container deployment with proxies
+- **[Humanization](./humanization.md)** โ Natural behavior patterns
\ No newline at end of file
diff --git a/docs/schedule.md b/docs/schedule.md
new file mode 100644
index 0000000..f7e9899
--- /dev/null
+++ b/docs/schedule.md
@@ -0,0 +1,648 @@
+# โฐ Scheduler & Automation
+
+
+
+**๐ Built-in scheduler for automated daily execution**
+*Set it and forget it*
+
+
+
+---
+
+## ๐ฏ What is the Scheduler?
+
+The built-in scheduler provides **automated script execution** at specified times without requiring external cron jobs or task schedulers.
+
+### **Key Features**
+- ๐
**Daily automation** โ Run at the same time every day
+- ๐ **Timezone aware** โ Handles DST automatically
+- ๐ **Multiple passes** โ Execute script multiple times per run
+- ๐๏ธ **Vacation mode** โ Skip random days monthly
+- ๐ฒ **Jitter support** โ Randomize execution times
+- โก **Immediate start** โ Option to run on startup
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "09:00",
+ "timeZone": "America/New_York",
+ "runImmediatelyOnStart": true
+ },
+ "passesPerRun": 2
+}
+```
+
+### **Advanced Setup with Vacation Mode**
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "10:00",
+ "timeZone": "Europe/Paris",
+ "runImmediatelyOnStart": false
+ },
+ "passesPerRun": 3,
+ "vacation": {
+ "enabled": true,
+ "minDays": 3,
+ "maxDays": 5
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `enabled` | `false` | Enable built-in scheduler |
+| `time` | `"09:00"` | Daily execution time (24-hour format) |
+| `timeZone` | `"UTC"` | IANA timezone identifier |
+| `runImmediatelyOnStart` | `true` | Execute once on process startup |
+| `passesPerRun` | `1` | Number of complete runs per execution |
+| `vacation.enabled` | `false` | Skip random monthly off-block |
+| `vacation.minDays` | `3` | Minimum vacation days |
+| `vacation.maxDays` | `5` | Maximum vacation days |
+
+---
+
+## ๐ How It Works
+
+### **Daily Scheduling**
+1. **Calculate next run** โ Timezone-aware scheduling
+2. **Wait until time** โ Minimal resource usage
+3. **Execute passes** โ Run script specified number of times
+4. **Schedule next day** โ Automatic DST adjustment
+
+### **Startup Behavior**
+
+#### **Immediate Start Enabled (`true`)**
+- **Before scheduled time** โ Run immediately + wait for next scheduled time
+- **After scheduled time** โ Run immediately + wait for tomorrow's time
+
+#### **Immediate Start Disabled (`false`)**
+- **Any time** โ Always wait for next scheduled time
+
+### **Multiple Passes**
+- Each pass processes **all accounts** through **all tasks**
+- Useful for **maximum point collection**
+- Higher passes = **more points** but **increased detection risk**
+
+---
+
+## ๐๏ธ Vacation Mode
+
+### **Monthly Off-Blocks**
+Vacation mode randomly selects a **contiguous block of days** each month to skip execution.
+
+### **Configuration**
+```json
+{
+ "vacation": {
+ "enabled": true,
+ "minDays": 3,
+ "maxDays": 5
+ }
+}
+```
+
+### **How It Works**
+- **Random selection** โ Different days each month
+- **Contiguous block** โ Skip consecutive days, not scattered
+- **Independent** โ Works with weekly random off-days
+- **Logged** โ Shows selected vacation period
+
+### **Example Output**
+```
+[SCHEDULE] Selected vacation block this month: 2025-01-15 โ 2025-01-18
+[SCHEDULE] Skipping run - vacation mode (3 days remaining)
+```
+
+---
+
+## ๐ Supported Timezones
+
+### **North America**
+- `America/New_York` โ Eastern Time
+- `America/Chicago` โ Central Time
+- `America/Denver` โ Mountain Time
+- `America/Los_Angeles` โ Pacific Time
+- `America/Phoenix` โ Arizona (no DST)
+
+### **Europe**
+- `Europe/London` โ GMT/BST
+- `Europe/Paris` โ CET/CEST
+- `Europe/Berlin` โ CET/CEST
+- `Europe/Rome` โ CET/CEST
+- `Europe/Moscow` โ MSK
+
+### **Asia Pacific**
+- `Asia/Tokyo` โ JST
+- `Asia/Shanghai` โ CST
+- `Asia/Kolkata` โ IST
+- `Australia/Sydney` โ AEST/AEDT
+- `Pacific/Auckland` โ NZST/NZDT
+
+---
+
+## ๐ฒ Randomization & Watchdog
+
+### **Environment Variables**
+```powershell
+# Add random delay before first run (5-20 minutes)
+$env:SCHEDULER_INITIAL_JITTER_MINUTES_MIN=5
+$env:SCHEDULER_INITIAL_JITTER_MINUTES_MAX=20
+
+# Add daily jitter to scheduled time (2-10 minutes)
+$env:SCHEDULER_DAILY_JITTER_MINUTES_MIN=2
+$env:SCHEDULER_DAILY_JITTER_MINUTES_MAX=10
+
+# Kill stuck passes after N minutes
+$env:SCHEDULER_PASS_TIMEOUT_MINUTES=180
+
+# Run each pass in separate process (recommended)
+$env:SCHEDULER_FORK_PER_PASS=true
+```
+
+### **Benefits**
+- **Avoid patterns** โ Prevents exact-time repetition
+- **Protection** โ Kills stuck processes
+- **Isolation** โ Process separation for stability
+
+---
+
+## ๐ฅ๏ธ Running the Scheduler
+
+### **Development Mode**
+```powershell
+npm run ts-schedule
+```
+
+### **Production Mode**
+```powershell
+npm run build
+npm run start:schedule
+```
+
+### **Background Execution**
+```powershell
+# Windows Background (PowerShell)
+Start-Process -NoNewWindow -FilePath "npm" -ArgumentList "run", "start:schedule"
+
+# Alternative: Windows Task Scheduler (recommended)
+# Create scheduled task via GUI or schtasks command
+```
+
+---
+
+## ๐ Usage Examples
+
+### **Basic Daily Automation**
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "08:00",
+ "timeZone": "America/New_York"
+ }
+}
+```
+โฐ **Perfect for morning routine** โ Catch daily resets
+
+### **Multiple Daily Passes**
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "10:00",
+ "timeZone": "Europe/London",
+ "runImmediatelyOnStart": false
+ },
+ "passesPerRun": 3
+}
+```
+๐ **Maximum points** with higher detection risk
+
+### **Conservative with Vacation**
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "20:00",
+ "timeZone": "America/Los_Angeles"
+ },
+ "passesPerRun": 1,
+ "vacation": {
+ "enabled": true,
+ "minDays": 4,
+ "maxDays": 6
+ }
+}
+```
+๐๏ธ **Natural patterns** with monthly breaks
+
+---
+
+## ๐ณ Docker Integration
+
+### **Built-in Scheduler (Recommended)**
+```yaml
+services:
+ microsoft-rewards-script:
+ build: .
+ environment:
+ TZ: Europe/Paris
+ command: ["npm", "run", "start:schedule"]
+```
+- Uses `passesPerRun` from config
+- Single long-running process
+- No external cron needed
+
+### **External Cron (Project Default)**
+```yaml
+services:
+ microsoft-rewards-script:
+ build: .
+ environment:
+ CRON_SCHEDULE: "0 7,16,20 * * *"
+ RUN_ON_START: "true"
+```
+- Uses `run_daily.sh` with random delays
+- Multiple cron executions
+- Lockfile prevents overlaps
+
+---
+
+## ๐ Logging Output
+
+### **Scheduler Initialization**
+```
+[SCHEDULE] Scheduler initialized for daily 09:00 America/New_York
+[SCHEDULE] Next run scheduled for 2025-01-21 09:00:00 EST
+```
+
+### **Daily Execution**
+```
+[SCHEDULE] Starting scheduled run (pass 1 of 2)
+[SCHEDULE] Completed scheduled run in 12m 34s
+[SCHEDULE] Next run scheduled for 2025-01-22 09:00:00 EST
+```
+
+### **Time Calculations**
+```
+[SCHEDULE] Current time: 2025-01-20 15:30:00 EDT
+[SCHEDULE] Target time: 2025-01-21 09:00:00 EDT
+[SCHEDULE] Waiting 17h 30m until next run
+```
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| **Scheduler not running** | Check `enabled: true`; verify timezone format |
+| **Wrong execution time** | Verify system clock; check DST effects |
+| **Memory growth** | Restart process weekly; monitor logs |
+| **Missed executions** | Check system sleep/hibernation; verify process |
+
+### **Debug Commands**
+```powershell
+# Test timezone calculation
+node -e "console.log(new Date().toLocaleString('en-US', {timeZone: 'America/New_York'}))"
+
+# Verify config syntax
+node -e "console.log(JSON.parse((Get-Content 'src/config.json' | Out-String)))"
+
+# Check running processes
+Get-Process | Where-Object {$_.ProcessName -eq "node"}
+```
+
+---
+
+## โก Performance & Best Practices
+
+### **Optimal Timing**
+- **๐
Morning (7-10 AM)** โ Catch daily resets
+- **๐ Evening (7-10 PM)** โ Complete remaining tasks
+- **โ Avoid peak hours** โ Reduce detection during high traffic
+
+### **Pass Recommendations**
+- **1 pass** โ Safest, good for most users
+- **2-3 passes** โ Balance of points vs. risk
+- **4+ passes** โ Higher risk, development only
+
+### **Monitoring**
+- โ
Check logs regularly for errors
+- โ
Monitor point collection trends
+- โ
Verify scheduler status weekly
+
+---
+
+## ๐ Alternative Solutions
+
+### **Windows Task Scheduler**
+```powershell
+# Create scheduled task
+schtasks /create /tn "MS-Rewards" /tr "npm start" /sc daily /st 09:00 /sd 01/01/2025
+```
+
+### **PowerShell Scheduled Job**
+```powershell
+# Register scheduled job
+Register-ScheduledJob -Name "MSRewards" -ScriptBlock {cd "C:\path\to\project"; npm start} -Trigger (New-JobTrigger -Daily -At 9am)
+```
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Humanization](./humanization.md)** โ Natural behavior patterns
+- **[Docker](./docker.md)** โ Container deployment
+- **[Job State](./jobstate.md)** โ Execution state management
+
+## Usage Examples
+
+### Basic Daily Run
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "08:00",
+ "timeZone": "America/New_York"
+ }
+}
+```
+
+### Multiple Daily Passes
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "10:00",
+ "timeZone": "Europe/London",
+ "runImmediatelyOnStart": false
+ },
+ "passesPerRun": 3
+}
+```
+
+### Development Testing
+```json
+{
+ "schedule": {
+ "enabled": true,
+ "time": "00:01",
+ "timeZone": "UTC",
+ "runImmediatelyOnStart": true
+ }
+}
+```
+
+## Supported Timezones
+
+Common IANA timezone identifiers:
+
+### North America
+- `America/New_York` (Eastern Time)
+- `America/Chicago` (Central Time)
+- `America/Denver` (Mountain Time)
+- `America/Los_Angeles` (Pacific Time)
+- `America/Phoenix` (Arizona - no DST)
+
+### Europe
+- `Europe/London` (GMT/BST)
+- `Europe/Paris` (CET/CEST)
+- `Europe/Berlin` (CET/CEST)
+- `Europe/Rome` (CET/CEST)
+- `Europe/Moscow` (MSK)
+
+### Asia Pacific
+- `Asia/Tokyo` (JST)
+- `Asia/Shanghai` (CST)
+- `Asia/Kolkata` (IST)
+- `Australia/Sydney` (AEST/AEDT)
+- `Pacific/Auckland` (NZST/NZDT)
+
+### UTC Variants
+- `UTC` (Coordinated Universal Time)
+- `GMT` (Greenwich Mean Time)
+
+## Running the Scheduler
+
+### Development Mode
+```bash
+npm run ts-schedule
+```
+
+### Production Mode
+```bash
+npm run build
+npm run start:schedule
+```
+
+### Optional Randomization and Watchdog
+
+You can introduce slight randomness to the start times and protect against stuck runs:
+
+- `SCHEDULER_INITIAL_JITTER_MINUTES_MIN` / `SCHEDULER_INITIAL_JITTER_MINUTES_MAX`
+ - Adds a oneโtime random delay before the very first run after the scheduler starts.
+ - Example: `SCHEDULER_INITIAL_JITTER_MINUTES_MIN=5` and `SCHEDULER_INITIAL_JITTER_MINUTES_MAX=20` delays the first run by 5โ20 minutes.
+
+- `SCHEDULER_DAILY_JITTER_MINUTES_MIN` / `SCHEDULER_DAILY_JITTER_MINUTES_MAX`
+ - Adds an extra random delay to each daily scheduled execution.
+ - Example: 2โ10 minutes of daily jitter to avoid exact same second each day.
+
+- `SCHEDULER_PASS_TIMEOUT_MINUTES`
+ - Kills a stuck pass after N minutes (default 180). Useful if the underlying browser gets stuck.
+
+- `SCHEDULER_FORK_PER_PASS`
+ - Defaults to `true`. When `true`, each pass runs in a child Node process so a stuck pass can be terminated without killing the scheduler. Set to `false` to run passes inโprocess (not recommended).
+
+### Background Execution
+```bash
+# Linux/macOS (background process)
+nohup npm run start:schedule > schedule.log 2>&1 &
+
+# Windows (background service - requires additional setup)
+# Recommend using Task Scheduler or Windows Service wrapper
+```
+
+## Process Management
+
+### Long-Running Process
+- Scheduler runs continuously
+- Automatically handles timezone changes
+- Graceful handling of system clock adjustments
+
+### Memory Management
+- Minimal memory footprint between runs
+- Garbage collection after each execution
+- No memory leaks in long-running processes
+
+### Error Recovery
+- Failed runs don't affect future scheduling
+- Automatic retry on next scheduled time
+- Error logging for troubleshooting
+
+## Logging Output
+
+### Scheduler Events
+```
+[SCHEDULE] Scheduler initialized for daily 09:00 America/New_York
+[SCHEDULE] Next run scheduled for 2025-09-21 09:00:00 EST
+[SCHEDULE] Starting scheduled run (pass 1 of 2)
+[SCHEDULE] Completed scheduled run in 12m 34s
+[SCHEDULE] Next run scheduled for 2025-09-22 09:00:00 EST
+```
+
+### Time Calculations
+```
+[SCHEDULE] Current time: 2025-09-20 15:30:00 EDT
+[SCHEDULE] Target time: 2025-09-21 09:00:00 EDT
+[SCHEDULE] Waiting 17h 30m until next run
+```
+
+## Integration with Other Features
+
+### Docker Compatibility
+- Scheduler works in Docker containers
+- Alternative to external cron jobs
+- Timezone handling in containerized environments
+
+### Buy Mode Exclusion
+- Scheduler only runs automation mode
+- Buy mode (`-buy`) ignores scheduler settings
+- Manual executions bypass scheduler
+
+### Clustering
+- Scheduler runs only in single-process mode
+- Clustering disabled when scheduler is active
+- Use scheduler OR clustering, not both
+
+## Best Practices
+
+### Optimal Timing
+- **Morning runs**: Catch daily resets and new activities
+- **Evening runs**: Complete remaining tasks before midnight
+- **Avoid peak hours**: Reduce detection risk during high traffic
+
+### Timezone Selection
+- Use your local timezone for easier monitoring
+- Consider Microsoft Rewards server timezone
+- Account for daylight saving time changes
+
+### Multiple Passes
+- **2-3 passes**: Good balance of points vs. detection risk
+- **More passes**: Higher detection risk
+- **Single pass**: Safest but may miss some points
+
+### Monitoring
+- Check logs regularly for errors
+- Monitor point collection trends
+- Verify scheduler is running as expected
+
+## Troubleshooting
+
+### Common Issues
+
+**Scheduler not running:**
+- Check `enabled: true` in config
+- Verify timezone format is correct
+- Ensure no syntax errors in config.json
+
+**Wrong execution time:**
+- Verify system clock is accurate
+- Check timezone identifier spelling
+- Consider daylight saving time effects
+
+**Memory growth over time:**
+- Restart scheduler process weekly
+- Monitor system resource usage
+- Check for memory leaks in logs
+
+**Missed executions:**
+- System was sleeping/hibernating
+- Process was killed or crashed
+- Clock was adjusted significantly
+
+### Debug Commands
+```bash
+# Test timezone calculation
+node -e "console.log(new Date().toLocaleString('en-US', {timeZone: 'America/New_York'}))"
+
+# Verify config syntax
+node -e "console.log(JSON.parse(require('fs').readFileSync('src/config.json')))"
+
+# Check process status
+ps aux | grep "start:schedule"
+```
+
+## Alternative Solutions
+
+### External Cron (Linux/macOS)
+```bash
+# Crontab entry for 9 AM daily
+0 9 * * * cd /path/to/MSN-V2 && npm start
+
+# Multiple times per day
+0 9,15,21 * * * cd /path/to/MSN-V2 && npm start
+```
+
+### Windows Task Scheduler
+- Create scheduled task via Task Scheduler
+- Set trigger for daily execution
+- Configure action to run `npm start` in project directory
+
+### Docker Cron
+```dockerfile
+# Add to Dockerfile
+RUN apt-get update && apt-get install -y cron
+COPY crontab /etc/cron.d/rewards-cron
+RUN crontab /etc/cron.d/rewards-cron
+```
+
+### Docker + Built-in Scheduler
+Au lieu d'utiliser cron, vous pouvez lancer le scheduler intรฉgrรฉ dans le conteneur (un seul process longโvivant)ย :
+
+```yaml
+services:
+ microsoft-rewards-script:
+ build: .
+ environment:
+ TZ: Europe/Paris
+ command: ["npm", "run", "start:schedule"]
+```
+
+Dans ce modeย :
+- `passesPerRun` fonctionne (exรฉcutera plusieurs passes ร chaque horaire interne dรฉfini par `src/config.json`).
+- Vous n'avez plus besoin de `CRON_SCHEDULE` ni de `run_daily.sh`.
+
+### Docker + External Cron (par dรฉfaut du projet)
+Si vous prรฉfรฉrez la planification par cron systรจme dans le conteneur (valeur par dรฉfaut du projet)ย :
+- Utilisez `CRON_SCHEDULE` (ex.: `0 7,16,20 * * *`).
+- `run_daily.sh` introduit un dรฉlai alรฉatoire (par dรฉfaut 5โ50 min) et un lockfile pour รฉviter les chevauchements.
+- `RUN_ON_START=true` dรฉclenche une exรฉcution immรฉdiate au dรฉmarrage du conteneur (sans dรฉlai alรฉatoire).
+
+## Performance Considerations
+
+### System Resources
+- Minimal CPU usage between runs
+- Low memory footprint when idle
+- No network activity during waiting periods
+
+### Startup Time
+- Fast initialization (< 1 second)
+- Quick timezone calculations
+- Immediate scheduling of next run
+
+### Reliability
+- Robust error handling
+- Automatic recovery from failures
+- Consistent execution timing
\ No newline at end of file
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..bf75c59
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,296 @@
+# ๐ Security & Privacy Guide
+
+
+
+**๐ก๏ธ Comprehensive security measures and incident response**
+*Protect your accounts and maintain privacy*
+
+
+
+---
+
+## ๐ฏ Security Overview
+
+This guide explains how the script **detects security-related issues**, what it does automatically, and how you can **resolve incidents** safely.
+
+### **Security Features**
+- ๐จ **Automated detection** โ Recognizes account compromise attempts
+- ๐ **Emergency halting** โ Stops all automation during incidents
+- ๐ **Strong alerts** โ Immediate notifications via Discord/NTFY
+- ๐ **Recovery guidance** โ Step-by-step incident resolution
+- ๐ **Privacy protection** โ Local-only operation by default
+
+---
+
+## ๐จ Security Incidents & Resolutions
+
+### **Recovery Email Mismatch**
+
+#### **Symptoms**
+During Microsoft login, the page shows a masked recovery email like `ko*****@hacker.net` that **doesn't match** your expected recovery email pattern.
+
+#### **What the Script Does**
+- ๐ **Halts automation** for the current account (leaves page open for manual action)
+- ๐จ **Sends strong alerts** to all channels and engages global standby
+- โธ๏ธ **Stops processing** โ No further accounts are processed
+- ๐ **Repeats reminders** every 5 minutes until intervention
+
+#### **Likely Causes**
+- โ ๏ธ **Account takeover** โ Recovery email changed by someone else
+- ๐ **Recent change** โ You changed recovery email but forgot to update config
+
+#### **How to Fix**
+1. **๐ Verify account security** in Microsoft Account settings
+2. **๐ Update config** if you changed recovery email yourself:
+ ```json
+ {
+ "email": "your@email.com",
+ "recoveryEmail": "ko*****@hacker.net"
+ }
+ ```
+3. **๐ Change password** and review sign-in activity if compromise suspected
+4. **๐ Restart script** to resume normal operation
+
+#### **Prevention**
+- โ
Keep `recoveryEmail` in `accounts.json` up to date
+- โ
Use strong unique passwords and MFA
+- โ
Regular security reviews
+
+---
+
+### **"We Can't Sign You In" (Blocked)**
+
+#### **Symptoms**
+Microsoft presents a page titled **"We can't sign you in"** during login attempts.
+
+#### **What the Script Does**
+- ๐ **Stops automation** and leaves page open for manual recovery
+- ๐จ **Sends strong alert** with high priority notifications
+- โธ๏ธ **Engages global standby** to avoid processing other accounts
+
+#### **Likely Causes**
+- โฑ๏ธ **Temporary lock** โ Rate limiting or security check from Microsoft
+- ๐ซ **Account restrictions** โ Ban related to unusual activity
+- ๐ **Verification required** โ SMS code, authenticator, or other challenges
+
+#### **How to Fix**
+1. **โ
Complete verification** challenges (SMS, authenticator, etc.)
+2. **โธ๏ธ Pause activity** for 24-48h if blocked repeatedly
+3. **๐ง Reduce concurrency** and increase delays between actions
+4. **๐ Check proxies** โ Ensure consistent IP/country
+5. **๐ Appeal if needed** โ Contact Microsoft if ban is suspected
+
+#### **Prevention**
+- โ
**Respect rate limits** โ Use humanization settings
+- โ
**Avoid patterns** โ Don't run too many accounts from same IP
+- โ
**Geographic consistency** โ Use proxies from your actual region
+- โ
**Human-like timing** โ Avoid frequent credential retries
+
+---
+
+## ๐ Privacy & Data Protection
+
+### **Local-First Architecture**
+- ๐พ **All data local** โ Credentials, sessions, logs stored locally only
+- ๐ซ **No telemetry** โ Zero data collection or external reporting
+- ๐ **No cloud storage** โ Everything remains on your machine
+
+### **Credential Security**
+```json
+{
+ "accounts": [
+ {
+ "email": "user@example.com",
+ "password": "secure-password-here",
+ "totpSecret": "optional-2fa-secret"
+ }
+ ]
+}
+```
+
+**Best Practices:**
+- ๐ **Strong passwords** โ Unique, complex passwords per account
+- ๐ **2FA enabled** โ Time-based one-time passwords when possible
+- ๐ **File permissions** โ Restrict access to `accounts.json`
+- ๐ **Regular rotation** โ Change passwords periodically
+
+### **Session Management**
+- ๐ช **Persistent cookies** โ Stored locally in `sessions/` directory
+- ๐ **Encrypted storage** โ Session data protected at rest
+- โฐ **Automatic expiry** โ Old sessions cleaned up automatically
+- ๐๏ธ **Per-account isolation** โ No session data mixing
+
+---
+
+## ๐ Network Security
+
+### **Proxy Configuration**
+```json
+{
+ "browser": {
+ "proxy": {
+ "enabled": true,
+ "server": "proxy.example.com:8080",
+ "username": "user",
+ "password": "pass"
+ }
+ }
+}
+```
+
+**Security Benefits:**
+- ๐ญ **IP masking** โ Hide your real IP address
+- ๐ **Geographic flexibility** โ Appear from different locations
+- ๐ **Traffic encryption** โ HTTPS proxy connections
+- ๐ก๏ธ **Detection avoidance** โ Rotate IPs to avoid patterns
+
+### **Traffic Analysis Protection**
+- ๐ **HTTPS only** โ All Microsoft communications encrypted
+- ๐ซ **No plaintext passwords** โ Credentials protected in transit
+- ๐ก๏ธ **Certificate validation** โ SSL/TLS verification enabled
+- ๐ **Deep packet inspection** resistant
+
+---
+
+## ๐ก๏ธ Anti-Detection Measures
+
+### **Humanization**
+```json
+{
+ "humanization": {
+ "enabled": true,
+ "actionDelay": { "min": 150, "max": 450 },
+ "gestureMoveProb": 0.4,
+ "gestureScrollProb": 0.2
+ }
+}
+```
+
+**Natural Behavior Simulation:**
+- โฑ๏ธ **Random delays** โ Variable timing between actions
+- ๐ฑ๏ธ **Mouse movements** โ Subtle cursor adjustments
+- ๐ **Scrolling gestures** โ Natural page interactions
+- ๐ฒ **Randomized patterns** โ Avoid predictable automation
+
+### **Browser Fingerprinting**
+- ๐ **Real user agents** โ Authentic browser identification
+- ๐ฑ **Platform consistency** โ Mobile/desktop specific headers
+- ๐ง **Plugin simulation** โ Realistic browser capabilities
+- ๐ฅ๏ธ **Screen resolution** โ Appropriate viewport dimensions
+
+---
+
+## ๐ Monitoring & Alerting
+
+### **Real-Time Monitoring**
+```json
+{
+ "notifications": {
+ "webhook": {
+ "enabled": true,
+ "url": "https://discord.com/api/webhooks/..."
+ },
+ "ntfy": {
+ "enabled": true,
+ "url": "https://ntfy.sh",
+ "topic": "rewards-security"
+ }
+ }
+}
+```
+
+**Alert Types:**
+- ๐จ **Security incidents** โ Account compromise attempts
+- โ ๏ธ **Login failures** โ Authentication issues
+- ๐ **Account blocks** โ Access restrictions detected
+- ๐ **Performance anomalies** โ Unusual execution patterns
+
+### **Log Analysis**
+- ๐ **Detailed logging** โ All actions recorded locally
+- ๐ **Error tracking** โ Failed operations highlighted
+- ๐ **Performance metrics** โ Timing and success rates
+- ๐ก๏ธ **Security events** โ Incident timeline reconstruction
+
+---
+
+## ๐งช Security Testing
+
+### **Penetration Testing**
+```powershell
+# Test credential handling
+$env:DEBUG_SECURITY=1; npm start
+
+# Test session persistence
+$env:DEBUG_SESSIONS=1; npm start
+
+# Test proxy configuration
+$env:DEBUG_PROXY=1; npm start
+```
+
+### **Vulnerability Assessment**
+- ๐ **Regular audits** โ Check for security issues
+- ๐ฆ **Dependency scanning** โ Monitor npm packages
+- ๐ **Code review** โ Manual security analysis
+- ๐ก๏ธ **Threat modeling** โ Identify attack vectors
+
+---
+
+## ๐ Security Checklist
+
+### **Initial Setup**
+- โ
**Strong passwords** for all accounts
+- โ
**2FA enabled** where possible
+- โ
**File permissions** restricted to user only
+- โ
**Proxy configured** if desired
+- โ
**Notifications set up** for alerts
+
+### **Regular Maintenance**
+- โ
**Password rotation** every 90 days
+- โ
**Session cleanup** weekly
+- โ
**Log review** for anomalies
+- โ
**Security updates** for dependencies
+- โ
**Backup verification** of configurations
+
+### **Incident Response**
+- โ
**Alert investigation** within 15 minutes
+- โ
**Account verification** when suspicious
+- โ
**Password changes** if compromise suspected
+- โ
**Activity review** in Microsoft account settings
+- โ
**Documentation** of incidents and resolutions
+
+---
+
+## ๐จ Emergency Procedures
+
+### **Account Compromise Response**
+1. **๐ Immediate shutdown** โ Stop all script activity
+2. **๐ Change passwords** โ Update all affected accounts
+3. **๐ Contact Microsoft** โ Report unauthorized access
+4. **๐ Audit activity** โ Review recent sign-ins and changes
+5. **๐ก๏ธ Enable additional security** โ Add 2FA, recovery options
+6. **๐ Document incident** โ Record timeline and actions taken
+
+### **Detection Evasion**
+1. **โธ๏ธ Temporary suspension** โ Pause automation for 24-48h
+2. **๐ง Reduce intensity** โ Lower pass counts and frequencies
+3. **๐ Change IPs** โ Rotate proxies or VPN endpoints
+4. **โฐ Adjust timing** โ Modify scheduling patterns
+5. **๐ญ Increase humanization** โ More natural behavior simulation
+
+---
+
+## ๐ Quick Reference Links
+
+When the script detects a security incident, it opens this guide directly to the relevant section:
+
+- **[Recovery Email Mismatch](#recovery-email-mismatch)** โ Email change detection
+- **[Account Blocked](#we-cant-sign-you-in-blocked)** โ Login restriction handling
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Accounts & 2FA](./accounts.md)** โ Microsoft account setup
+- **[Proxy Configuration](./proxy.md)** โ Network privacy and routing
+- **[Humanization](./humanization.md)** โ Natural behavior patterns
diff --git a/docs/update.md b/docs/update.md
new file mode 100644
index 0000000..9efad73
--- /dev/null
+++ b/docs/update.md
@@ -0,0 +1,395 @@
+# ๐ Auto-Update System
+
+
+
+**๐ Automatic updates to keep your installation current**
+*Set it and forget it*
+
+
+
+---
+
+## ๐ฏ What is Auto-Update?
+
+The automatic update system runs **after script completion** to keep your installation current with the latest features, bug fixes, and security patches.
+
+### **Key Features**
+- ๐ **Automatic updates** โ Runs after each script completion
+- ๐ก๏ธ **Safe by design** โ Fast-forward only Git updates
+- ๐ณ **Docker support** โ Container image updates
+- ๐ ๏ธ **Custom scripts** โ Extensible update process
+- ๐ **Error resilient** โ Failed updates don't break main script
+
+---
+
+## โ๏ธ Configuration
+
+### **Basic Setup**
+```json
+{
+ "update": {
+ "git": true,
+ "docker": false,
+ "scriptPath": "setup/update/update.mjs"
+ }
+}
+```
+
+### **Configuration Options**
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| `git` | Enable Git-based updates | `true` |
+| `docker` | Enable Docker container updates | `false` |
+| `scriptPath` | Path to custom update script | `"setup/update/update.mjs"` |
+
+---
+
+## ๐ Update Methods
+
+### **Git Updates (`git: true`)**
+
+#### **What It Does**
+- ๐ฅ **Fetches latest changes** from remote repository
+- โก **Fast-forward only pulls** (safe updates)
+- ๐ฆ **Reinstalls dependencies** (`npm ci`)
+- ๐จ **Rebuilds the project** (`npm run build`)
+
+#### **Requirements**
+- โ
Git installed and available in PATH
+- โ
Repository is a Git clone (not downloaded ZIP)
+- โ
No uncommitted local changes
+- โ
Internet connectivity
+
+#### **Process**
+```bash
+git fetch --all --prune
+git pull --ff-only
+npm ci
+npm run build
+```
+
+### **Docker Updates (`docker: true`)**
+
+#### **What It Does**
+- ๐ฅ **Pulls latest container images**
+- ๐ **Restarts services** with new images
+- ๐พ **Preserves configurations** and mounted volumes
+
+#### **Requirements**
+- โ
Docker and Docker Compose installed
+- โ
`docker-compose.yml` file present
+- โ
Proper container registry access
+
+#### **Process**
+```bash
+docker compose pull
+docker compose up -d
+```
+
+---
+
+## ๐ ๏ธ Custom Update Scripts
+
+### **Default Script**
+- **Path** โ `setup/update/update.mjs`
+- **Format** โ ES modules
+- **Arguments** โ Command line flags
+
+### **Script Arguments**
+- `--git` โ Enable Git update process
+- `--docker` โ Enable Docker update process
+- Both flags can be combined
+
+### **Custom Script Example**
+```javascript
+// custom-update.mjs
+import { execSync } from 'child_process'
+
+const args = process.argv.slice(2)
+
+if (args.includes('--git')) {
+ console.log('๐ Running custom Git update...')
+ execSync('git pull && npm install', { stdio: 'inherit' })
+}
+
+if (args.includes('--docker')) {
+ console.log('๐ณ Running custom Docker update...')
+ execSync('docker-compose pull && docker-compose up -d', { stdio: 'inherit' })
+}
+```
+
+---
+
+## โฐ Execution Timing
+
+### **When Updates Run**
+| Scenario | Update Runs |
+|----------|-------------|
+| **Normal completion** | โ
All accounts processed successfully |
+| **Error completion** | โ
Script finished with errors but completed |
+| **Interruption** | โ Script killed or crashed mid-execution |
+
+### **Update Sequence**
+1. **๐ Main script completion** โ All accounts processed
+2. **๐ Conclusion webhook** sent (if enabled)
+3. **๐ Update process begins**
+4. **๐ฅ Git updates** (if enabled)
+5. **๐ณ Docker updates** (if enabled)
+6. **๐ Process exits**
+
+---
+
+## ๐ก๏ธ Safety Features
+
+### **Git Safety**
+- โก **Fast-forward only** โ Prevents overwriting local changes
+- ๐ฆ **Dependency verification** โ Ensures `npm ci` succeeds
+- ๐จ **Build validation** โ Confirms TypeScript compilation works
+
+### **Error Handling**
+- โ
**Update failures** don't break main script
+- ๐ **Silent failures** โ Errors logged but don't crash process
+- ๐ **Rollback protection** โ Failed updates don't affect current installation
+
+### **Concurrent Execution**
+- ๐ **Single update process** โ Multiple instances don't conflict
+- ๐ซ **Lock-free design** โ No file locking needed
+- ๐ฏ **Independent updates** โ Each script copy updates separately
+
+---
+
+## ๐ Monitoring Updates
+
+### **Log Output**
+```
+[UPDATE] Starting post-run update process
+[UPDATE] Git update enabled, Docker update disabled
+[UPDATE] Running: git fetch --all --prune
+[UPDATE] Running: git pull --ff-only
+[UPDATE] Running: npm ci
+[UPDATE] Running: npm run build
+[UPDATE] Update completed successfully
+```
+
+### **Update Verification**
+```powershell
+# Check if updates are pending
+git status
+
+# View recent commits
+git log --oneline -5
+
+# Verify build status
+npm run build
+```
+
+---
+
+## ๐ Use Cases
+
+### **Development Environment**
+| Benefit | Description |
+|---------|-------------|
+| **Synchronized** | Keep local installation current with repository |
+| **Automated** | Automatic dependency updates |
+| **Seamless** | Integration of bug fixes and features |
+
+### **Production Deployment**
+| Benefit | Description |
+|---------|-------------|
+| **Security** | Automated security patches |
+| **Features** | Updates without manual intervention |
+| **Consistent** | Same update process across servers |
+
+### **Docker Environments**
+| Benefit | Description |
+|---------|-------------|
+| **Images** | Container image updates |
+| **Security** | Patches in base images |
+| **Automated** | Service restarts |
+
+---
+
+## ๐ Best Practices
+
+### **Git Configuration**
+- ๐งน **Clean working directory** โ Commit or stash local changes
+- ๐ฟ **Stable branch** โ Use `main` or `stable` for auto-updates
+- ๐ **Regular commits** โ Keep repository history clean
+- ๐พ **Backup data** โ Sessions and accounts before updates
+
+### **Docker Configuration**
+- ๐ท๏ธ **Image tagging** โ Use specific tags, not `latest` for production
+- ๐พ **Volume persistence** โ Ensure data volumes are mounted
+- ๐ **Service dependencies** โ Configure proper startup order
+- ๐ฏ **Resource limits** โ Set appropriate memory and CPU limits
+
+### **Monitoring**
+- ๐ **Check logs regularly** โ Monitor update success/failure
+- ๐งช **Test after updates** โ Verify script functionality
+- ๐พ **Backup configurations** โ Preserve working setups
+- ๐ **Version tracking** โ Record successful versions
+
+---
+
+## ๐ ๏ธ Troubleshooting
+
+### **Git Issues**
+
+| Error | Solution |
+|-------|----------|
+| **"Not a git repository"** | Clone repository instead of downloading ZIP |
+| **"Local changes would be overwritten"** | Commit or stash local changes |
+| **"Fast-forward not possible"** | Repository diverged - reset to remote state |
+
+#### **Git Reset Command**
+```powershell
+# Reset to remote state (โ ๏ธ loses local changes)
+git fetch origin
+git reset --hard origin/main
+```
+
+### **Docker Issues**
+
+| Error | Solution |
+|-------|----------|
+| **"Docker not found"** | Install Docker and Docker Compose |
+| **"Permission denied"** | Add user to docker group |
+| **"No docker-compose.yml"** | Create compose file or use custom script |
+
+#### **Docker Permission Fix**
+```powershell
+# Windows: Ensure Docker Desktop is running
+# Linux: Add user to docker group
+sudo usermod -aG docker $USER
+```
+
+### **Network Issues**
+
+| Error | Solution |
+|-------|----------|
+| **"Could not resolve host"** | Check internet connectivity |
+| **"Connection timeout"** | Check firewall and proxy settings |
+
+---
+
+## ๐ง Manual Updates
+
+### **Git Manual Update**
+```powershell
+git fetch --all --prune
+git pull --ff-only
+npm ci
+npm run build
+```
+
+### **Docker Manual Update**
+```powershell
+docker compose pull
+docker compose up -d
+```
+
+### **Dependencies Only**
+```powershell
+npm ci
+npm run build
+```
+
+---
+
+## โ๏ธ Update Configuration
+
+### **Complete Disable**
+```json
+{
+ "update": {
+ "git": false,
+ "docker": false
+ }
+}
+```
+
+### **Selective Enable**
+```json
+{
+ "update": {
+ "git": true, // Keep Git updates
+ "docker": false // Disable Docker updates
+ }
+}
+```
+
+### **Custom Script Path**
+```json
+{
+ "update": {
+ "git": true,
+ "docker": false,
+ "scriptPath": "my-custom-update.mjs"
+ }
+}
+```
+
+---
+
+## ๐ Security Considerations
+
+### **Git Security**
+- โ
**Trusted remote** โ Updates pull from configured remote only
+- โก **Fast-forward only** โ Prevents malicious rewrites
+- ๐ฆ **NPM registry** โ Dependencies from official registry
+
+### **Docker Security**
+- ๐ท๏ธ **Verified images** โ Container images from configured registries
+- โ๏ธ **Image signatures** โ Verify when possible
+- ๐ **Security scanning** โ Regular scanning of base images
+
+### **Script Execution**
+- ๐ค **Same permissions** โ Update scripts run with same privileges
+- ๐ซ **No escalation** โ No privilege escalation during updates
+- ๐ **Review scripts** โ Custom scripts should be security reviewed
+
+---
+
+## ๐ฏ Environment Examples
+
+### **Development**
+```json
+{
+ "update": {
+ "git": true,
+ "docker": false
+ }
+}
+```
+
+### **Production**
+```json
+{
+ "update": {
+ "git": false,
+ "docker": true
+ }
+}
+```
+
+### **Hybrid**
+```json
+{
+ "update": {
+ "git": true,
+ "docker": true,
+ "scriptPath": "setup/update/production-update.mjs"
+ }
+}
+```
+
+---
+
+## ๐ Related Guides
+
+- **[Getting Started](./getting-started.md)** โ Initial setup and configuration
+- **[Docker](./docker.md)** โ Container deployment and management
+- **[Scheduler](./schedule.md)** โ Automated timing and execution
+- **[Security](./security.md)** โ Privacy and data protection
\ No newline at end of file
diff --git a/entrypoint.sh b/entrypoint.sh
deleted file mode 100755
index 0a4e46b..0000000
--- a/entrypoint.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Ensure Playwright uses preinstalled browsers
-export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
-
-# 1. Timezone: default to UTC if not provided
-: "${TZ:=UTC}"
-ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
-echo "$TZ" > /etc/timezone
-dpkg-reconfigure -f noninteractive tzdata
-
-# 2. Validate CRON_SCHEDULE
-if [ -z "${CRON_SCHEDULE:-}" ]; then
- echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2
- echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2
- exit 1
-fi
-
-# 3. Initial run without sleep if RUN_ON_START=true
-if [ "${RUN_ON_START:-false}" = "true" ]; then
- echo "[entrypoint] Starting initial run in background at $(date)"
- (
- cd /usr/src/microsoft-rewards-script || {
- echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2
- exit 1
- }
- # Skip random sleep for initial run, but preserve setting for cron jobs
- SKIP_RANDOM_SLEEP=true src/run_daily.sh
- echo "[entrypoint-bg] Initial run completed at $(date)"
- ) &
- echo "[entrypoint] Background process started (PID: $!)"
-fi
-
-# 4. Template and register cron file with explicit timezone export
-if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then
- echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2
- exit 1
-fi
-
-# Export TZ for envsubst to use
-export TZ
-envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron
-chmod 0644 /etc/cron.d/microsoft-rewards-cron
-crontab /etc/cron.d/microsoft-rewards-cron
-
-echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)"
-
-# 5. Start cron in foreground (PID 1)
-exec cron -f
\ No newline at end of file
diff --git a/package.json b/package.json
index 25199de..23ea0f9 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,23 @@
{
"name": "microsoft-rewards-script",
- "version": "1.5.3",
+ "version": "2.0.0",
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
"main": "index.js",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
- "pre-build": "npm i && rimraf dist && npx playwright install chromium",
+ "clean": "rimraf dist",
+ "pre-build": "npm i && npm run clean && node -e \"process.exit(process.env.SKIP_PLAYWRIGHT_INSTALL?0:1)\" || npx playwright install chromium",
+ "typecheck": "tsc --noEmit",
"build": "tsc",
- "start": "node ./dist/index.js",
- "ts-start": "ts-node ./src/index.ts",
+ "start": "node --enable-source-maps ./dist/index.js",
+ "ts-start": "node --loader ts-node/esm ./src/index.ts",
"dev": "ts-node ./src/index.ts -dev",
+ "ts-schedule": "ts-node ./src/scheduler.ts",
+ "start:schedule": "node --enable-source-maps ./dist/scheduler.js",
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
+ "prepare": "npm run build",
"setup": "node ./setup/setup.mjs",
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"",
"create-docker": "docker build -t microsoft-rewards-script-docker ."
@@ -45,6 +51,7 @@
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"ms": "^2.1.3",
+ "luxon": "^3.5.0",
"playwright": "1.52.0",
"rebrowser-playwright": "1.52.0",
"socks-proxy-agent": "^8.0.5",
diff --git a/run.sh b/run.sh
old mode 100755
new mode 100644
diff --git a/setup/update/update.mjs b/setup/update/update.mjs
new file mode 100644
index 0000000..0a000bc
--- /dev/null
+++ b/setup/update/update.mjs
@@ -0,0 +1,67 @@
+/* eslint-disable linebreak-style */
+/**
+ * Post-run auto-update script
+ * - If invoked with --git, runs: git fetch --all --prune; git pull --ff-only; npm ci; npm run build
+ * - If invoked with --docker, runs: docker compose pull; docker compose up -d
+ *
+ * Usage:
+ * node setup/update/update.mjs --git
+ * node setup/update/update.mjs --docker
+ *
+ * Notes:
+ * - Commands are safe-by-default: use --ff-only for pull to avoid merge commits.
+ * - Script is no-op if the relevant tool is not available or commands fail.
+ */
+
+import { spawn } from 'node:child_process'
+
+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])
+ return code === 0
+}
+
+async function updateGit() {
+ const hasGit = await which('git')
+ if (!hasGit) return 1
+ await run('git', ['fetch', '--all', '--prune'])
+ const pullCode = await run('git', ['pull', '--ff-only'])
+ if (pullCode !== 0) return pullCode
+ const hasNpm = await which('npm')
+ if (!hasNpm) return 0
+ await run('npm', ['ci'])
+ return run('npm', ['run', 'build'])
+}
+
+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()
+ }
+ process.exit(code)
+}
+
+main().catch(() => process.exit(1))
diff --git a/src/accounts.example.json b/src/accounts.example.json
index ef7718a..8af141d 100644
--- a/src/accounts.example.json
+++ b/src/accounts.example.json
@@ -1,24 +1,31 @@
-[
+{
+ "_note": "Microsoft allows up to 3 new accounts per IP per day; in practice it is recommended not to exceed around 5 active accounts per household IP to avoid looking suspicious; there is no official lifetime cap, but creating too many accounts quickly may trigger verification (phone, OTP, captcha); Microsoft discourages bulk account creation from the same IP; unusual activity can result in temporary blocks or account restrictions.",
+ "accounts": [
{
- "email": "email_1",
- "password": "password_1",
- "proxy": {
- "proxyAxios": true,
- "url": "",
- "port": 0,
- "username": "",
- "password": ""
- }
+ "email": "email_1",
+ "password": "password_1",
+ "totp": "",
+ "recoveryEmail": "your_email@domain.com",
+ "proxy": {
+ "proxyAxios": true,
+ "url": "",
+ "port": 0,
+ "username": "",
+ "password": ""
+ }
},
{
- "email": "email_2",
- "password": "password_2",
- "proxy": {
- "proxyAxios": true,
- "url": "",
- "port": 0,
- "username": "",
- "password": ""
- }
+ "email": "email_2",
+ "password": "password_2",
+ "totp": "",
+ "recoveryEmail": "your_email@domain.com",
+ "proxy": {
+ "proxyAxios": true,
+ "url": "",
+ "port": 0,
+ "username": "",
+ "password": ""
+ }
}
-]
\ No newline at end of file
+ ]
+}
\ No newline at end of file
diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts
index 1aeac5f..6672d91 100644
--- a/src/browser/Browser.ts
+++ b/src/browser/Browser.ts
@@ -29,16 +29,25 @@ class Browser {
if (process.env.AUTO_INSTALL_BROWSERS === '1') {
try {
// Dynamically import child_process to avoid overhead otherwise
- const { execSync } = await import('child_process') as any
+ const { execSync } = await import('child_process')
execSync('npx playwright install chromium', { stdio: 'ignore' })
} catch { /* silent */ }
}
- let browser: any
+ let browser: import('rebrowser-playwright').Browser
+ // Support both legacy and new config structures (wider scope for later usage)
+ const cfgAny = this.bot.config as unknown as Record
try {
+ // FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
+ const envForceHeadless = process.env.FORCE_HEADLESS === '1'
+ const headlessValue = envForceHeadless ? true : ((cfgAny['headless'] as boolean | undefined) ?? (cfgAny['browser'] && (cfgAny['browser'] as Record)['headless'] as boolean | undefined) ?? false)
+ const headless: boolean = Boolean(headlessValue)
+
+ const engineName = 'chromium' // current hard-coded engine
+ this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
browser = await playwright.chromium.launch({
//channel: 'msedge', // Uses Edge instead of chrome
- headless: this.bot.config.headless,
+ headless,
...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }),
args: [
'--no-sandbox',
@@ -49,7 +58,7 @@ class Browser {
'--ignore-ssl-errors'
]
})
- } catch (e: any) {
+ } catch (e: unknown) {
const msg = (e instanceof Error ? e.message : String(e))
// Common missing browser executable guidance
if (/Executable doesn't exist/i.test(msg)) {
@@ -60,18 +69,57 @@ class Browser {
throw e
}
- const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, this.bot.config.saveFingerprint)
+ // Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
+ const fpConfig = (cfgAny['saveFingerprint'] as unknown) || ((cfgAny['fingerprinting'] as Record | undefined)?.['saveFingerprint'] as unknown)
+ const saveFingerprint: { mobile: boolean; desktop: boolean } = (fpConfig as { mobile: boolean; desktop: boolean }) || { mobile: false, desktop: false }
+
+ 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 any, { fingerprint: fingerprint })
+ const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
- // Set timeout to preferred amount
- context.setDefaultTimeout(this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? 30000))
+ // Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
+ const globalTimeout = (cfgAny['globalTimeout'] as unknown) ?? ((cfgAny['browser'] as Record | undefined)?.['globalTimeout'] as unknown) ?? 30000
+ context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout as (number | string)))
+
+ // Normalize viewport and page rendering so content fits typical screens
+ try {
+ const desktopViewport = { width: 1280, height: 800 }
+ const mobileViewport = { width: 390, height: 844 }
+
+ context.on('page', async (page) => {
+ try {
+ // Set a reasonable viewport size depending on device type
+ if (this.bot.isMobile) {
+ await page.setViewportSize(mobileViewport)
+ } else {
+ await page.setViewportSize(desktopViewport)
+ }
+
+ // Inject a tiny CSS to avoid gigantic scaling on some environments
+ await page.addInitScript(() => {
+ try {
+ const style = document.createElement('style')
+ style.id = '__mrs_fit_style'
+ style.textContent = `
+ html, body { overscroll-behavior: contain; }
+ /* Mild downscale to keep content within window on very large DPI */
+ @media (min-width: 1000px) {
+ html { zoom: 0.9 !important; }
+ }
+ `
+ document.documentElement.appendChild(style)
+ } catch { /* ignore */ }
+ })
+ } catch { /* ignore */ }
+ })
+ } catch { /* ignore */ }
await context.addCookies(sessionData.cookies)
- if (this.bot.config.saveFingerprint) {
+ // Persist fingerprint when feature is configured
+ if (fpConfig) {
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
}
diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts
index f60bf05..d25f2cb 100644
--- a/src/browser/BrowserFunc.ts
+++ b/src/browser/BrowserFunc.ts
@@ -40,10 +40,17 @@ export default class BrowserFunc {
await this.bot.utils.wait(3000)
await this.bot.browser.utils.tryDismissAllMessages(page)
- // Check if account is suspended
- const isSuspended = await page.waitForSelector('#suspendedAccountHeader', { state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)
- if (isSuspended) {
- this.bot.log(this.bot.isMobile, 'GO-HOME', 'This account is suspended!', 'error')
+ // Check if account is suspended (multiple heuristics)
+ const suspendedByHeader = await page.waitForSelector('#suspendedAccountHeader', { state: 'visible', timeout: 1500 }).then(() => true).catch(() => false)
+ let suspendedByText = false
+ if (!suspendedByHeader) {
+ try {
+ const text = (await page.textContent('body')) || ''
+ suspendedByText = /account has been suspended|suspended due to unusual activity/i.test(text)
+ } catch { /* ignore */ }
+ }
+ if (suspendedByHeader || suspendedByText) {
+ this.bot.log(this.bot.isMobile, 'GO-HOME', 'This account appears suspended!', 'error')
throw new Error('Account has been suspended!')
}
@@ -82,21 +89,22 @@ export default class BrowserFunc {
* Fetch user dashboard data
* @returns {DashboardData} Object of user bing rewards dashboard data
*/
- async getDashboardData(): Promise {
+ async getDashboardData(page?: Page): Promise {
+ const target = page ?? this.bot.homePage
const dashboardURL = new URL(this.bot.config.baseURL)
- const currentURL = new URL(this.bot.homePage.url())
+ const currentURL = new URL(target.url())
try {
// Should never happen since tasks are opened in a new tab!
if (currentURL.hostname !== dashboardURL.hostname) {
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
- await this.goHome(this.bot.homePage)
+ await this.goHome(target)
}
- let lastError: any = null
+ let lastError: unknown = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
// Reload the page to get new data
- await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' })
+ await target.reload({ waitUntil: 'domcontentloaded' })
lastError = null
break
} catch (re) {
@@ -108,7 +116,7 @@ export default class BrowserFunc {
if (attempt === 1) {
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
try {
- await this.goHome(this.bot.homePage)
+ await this.goHome(target)
} catch {/* ignore */}
} else {
break
@@ -119,7 +127,7 @@ export default class BrowserFunc {
}
}
- const scriptContent = await this.bot.homePage.evaluate(() => {
+ const scriptContent = await target.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script'))
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
@@ -131,7 +139,7 @@ export default class BrowserFunc {
}
// Extract the dashboard object from the script content
- const dashboardData = await this.bot.homePage.evaluate((scriptContent: string) => {
+ const dashboardData = await target.evaluate((scriptContent: string) => {
// Extract the dashboard object using regex
const regex = /var dashboard = (\{.*?\});/s
const match = regex.exec(scriptContent)
@@ -232,8 +240,12 @@ export default class BrowserFunc {
]
const data = await this.getDashboardData()
- let geoLocale = data.userProfile.attributes.country
- geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
+ // Guard against missing profile/attributes and undefined settings
+ let geoLocale = data?.userProfile?.attributes?.country || 'US'
+ const useGeo = !!(this.bot?.config?.searchSettings?.useGeoLocaleQueries)
+ geoLocale = (useGeo && typeof geoLocale === 'string' && geoLocale.length === 2)
+ ? geoLocale.toLowerCase()
+ : 'us'
const userDataRequest: AxiosRequestConfig = {
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613',
@@ -295,9 +307,10 @@ export default class BrowserFunc {
const html = await page.content()
const $ = load(html)
- const scriptContent = $('script').filter((index: number, element: any) => {
- return $(element).text().includes('_w.rewardsQuizRenderInfo')
- }).text()
+ const scriptContent = $('script')
+ .toArray()
+ .map(el => $(el).text())
+ .find(t => t.includes('_w.rewardsQuizRenderInfo')) || ''
if (scriptContent) {
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
@@ -355,7 +368,10 @@ export default class BrowserFunc {
const html = await page.content()
const $ = load(html)
- const element = $('.offer-cta').toArray().find((x: any) => x.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/browser/BrowserUtil.ts b/src/browser/BrowserUtil.ts
index 7952e23..078883e 100644
--- a/src/browser/BrowserUtil.ts
+++ b/src/browser/BrowserUtil.ts
@@ -12,52 +12,57 @@ export default class BrowserUtil {
}
async tryDismissAllMessages(page: Page): Promise {
- const buttons = [
+ const attempts = 3
+ const buttonGroups: { selector: string; label: string; isXPath?: boolean }[] = [
{ selector: '#acceptButton', label: 'AcceptButton' },
- { selector: '.ext-secondary.ext-button', label: '"Skip for now" Button' },
- { selector: '#iLandingViewAction', label: 'iLandingViewAction' },
- { selector: '#iShowSkip', label: 'iShowSkip' },
- { selector: '#iNext', label: 'iNext' },
- { selector: '#iLooksGood', label: 'iLooksGood' },
- { selector: '#idSIButton9', label: 'idSIButton9' },
- { selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' },
- { selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Button' },
- { selector: '.maybe-later', label: 'Mobile Rewards App Banner' },
- { selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Accept Cookie Consent Container', isXPath: true },
- { selector: '#bnp_btn_accept', label: 'Bing Cookie Banner' },
- { selector: '#reward_pivot_earn', label: 'Reward Coupon Accept' }
+ { selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
+ { selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
+ { selector: '#iLandingViewAction', label: 'Landing Continue' },
+ { selector: '#iShowSkip', label: 'Show Skip' },
+ { selector: '#iNext', label: 'Next' },
+ { selector: '#iLooksGood', label: 'LooksGood' },
+ { selector: '#idSIButton9', label: 'PrimaryLoginButton' },
+ { selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
+ { selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
+ { selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
+ { selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
+ { selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
+ { selector: '#bnp_close_link', label: 'Bing Cookie Close' },
+ { selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
+ { selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
]
-
- for (const button of buttons) {
+ for (let round = 0; round < attempts; round++) {
+ let dismissedThisRound = 0
+ for (const btn of buttonGroups) {
+ try {
+ const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
+ if (await loc.first().isVisible({ timeout: 200 }).catch(()=>false)) {
+ await loc.first().click({ timeout: 500 }).catch(()=>{})
+ dismissedThisRound++
+ this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
+ await page.waitForTimeout(150)
+ }
+ } catch { /* ignore */ }
+ }
+ // Special case: blocking overlay with inside buttons
try {
- const element = button.isXPath ? page.locator(`xpath=${button.selector}`) : page.locator(button.selector)
- await element.first().click({ timeout: 500 })
- await page.waitForTimeout(500)
-
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${button.label}`)
-
- } catch (error) {
- // Silent fail
- }
- }
-
- // Handle blocking Bing privacy overlay intercepting clicks (#bnp_overlay_wrapper)
- try {
- const overlay = await page.locator('#bnp_overlay_wrapper').first()
- if (await overlay.isVisible({ timeout: 500 }).catch(()=>false)) {
- // Try common dismiss buttons inside overlay
- const rejectBtn = await page.locator('#bnp_btn_reject, button[aria-label*="Reject" i]').first()
- const acceptBtn = await page.locator('#bnp_btn_accept').first()
- if (await rejectBtn.isVisible().catch(()=>false)) {
- await rejectBtn.click({ timeout: 500 }).catch(()=>{})
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject')
- } else if (await acceptBtn.isVisible().catch(()=>false)) {
- await acceptBtn.click({ timeout: 500 }).catch(()=>{})
- this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Accept (fallback)')
+ const overlay = page.locator('#bnp_overlay_wrapper')
+ if (await overlay.isVisible({ timeout: 200 }).catch(()=>false)) {
+ const reject = overlay.locator('#bnp_btn_reject, button[aria-label*="Reject" i]')
+ const accept = overlay.locator('#bnp_btn_accept')
+ if (await reject.first().isVisible().catch(()=>false)) {
+ await reject.first().click({ timeout: 500 }).catch(()=>{})
+ this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
+ dismissedThisRound++
+ } else if (await accept.first().isVisible().catch(()=>false)) {
+ await accept.first().click({ timeout: 500 }).catch(()=>{})
+ this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
+ dismissedThisRound++
+ }
}
- await page.waitForTimeout(300)
- }
- } catch { /* ignore */ }
+ } catch { /* ignore */ }
+ if (dismissedThisRound === 0) break // nothing new dismissed -> stop early
+ }
}
async getLatestTab(page: Page): Promise {
@@ -78,40 +83,6 @@ export default class BrowserUtil {
}
}
- async getTabs(page: Page) {
- try {
- const browser = page.context()
- const pages = browser.pages()
-
- const homeTab = pages[1]
- let homeTabURL: URL
-
- if (!homeTab) {
- throw this.bot.log(this.bot.isMobile, 'GET-TABS', 'Home tab could not be found!', 'error')
-
- } else {
- homeTabURL = new URL(homeTab.url())
-
- if (homeTabURL.hostname !== 'rewards.bing.com') {
- throw this.bot.log(this.bot.isMobile, 'GET-TABS', 'Reward page hostname is invalid: ' + homeTabURL.host, 'error')
- }
- }
-
- const workerTab = pages[2]
- if (!workerTab) {
- throw this.bot.log(this.bot.isMobile, 'GET-TABS', 'Worker tab could not be found!', 'error')
- }
-
- return {
- homeTab: homeTab,
- workerTab: workerTab
- }
-
- } catch (error) {
- throw this.bot.log(this.bot.isMobile, 'GET-TABS', 'An error occurred:' + error, 'error')
- }
- }
-
async reloadBadPage(page: Page): Promise {
try {
const html = await page.content().catch(() => '')
@@ -129,4 +100,80 @@ export default class BrowserUtil {
}
}
+ /**
+ * Perform small human-like gestures: short waits, minor mouse moves and occasional scrolls.
+ * This should be called sparingly between actions to avoid a fixed cadence.
+ */
+ async humanizePage(page: Page): Promise {
+ try {
+ const h = this.bot.config?.humanization || {}
+ if (h.enabled === false) return
+ const moveProb = typeof h.gestureMoveProb === 'number' ? h.gestureMoveProb : 0.4
+ const scrollProb = typeof h.gestureScrollProb === 'number' ? h.gestureScrollProb : 0.2
+ // minor mouse move
+ if (Math.random() < moveProb) {
+ const x = Math.floor(Math.random() * 30) + 5
+ const y = Math.floor(Math.random() * 20) + 3
+ await page.mouse.move(x, y, { steps: 2 }).catch(() => { })
+ }
+ // tiny scroll
+ if (Math.random() < scrollProb) {
+ const dy = (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 150) + 50)
+ await page.mouse.wheel(0, dy).catch(() => { })
+ }
+ // Random short wait; override via humanization.actionDelay
+ const range = h.actionDelay
+ if (range && typeof range.min !== 'undefined' && typeof range.max !== 'undefined') {
+ try {
+ const ms = (await import('ms')).default
+ const min = typeof range.min === 'number' ? range.min : ms(String(range.min))
+ const max = typeof range.max === 'number' ? range.max : ms(String(range.max))
+ if (typeof min === 'number' && typeof max === 'number' && max >= min) {
+ await this.bot.utils.wait(this.bot.utils.randomNumber(Math.max(0, min), Math.min(max, 5000)))
+ } else {
+ await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
+ }
+ } catch {
+ await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
+ }
+ } else {
+ await this.bot.utils.wait(this.bot.utils.randomNumber(150, 450))
+ }
+ } catch { /* swallow */ }
+ }
+
+ /**
+ * Capture minimal diagnostics for a page: screenshot + HTML content.
+ * Files are written under ./reports// with a safe label.
+ */
+ async captureDiagnostics(page: Page, label: string): Promise {
+ try {
+ const cfg = this.bot.config?.diagnostics || {}
+ if (cfg.enabled === false) return
+ const maxPerRun = typeof cfg.maxPerRun === 'number' ? cfg.maxPerRun : 8
+ if (!this.bot.tryReserveDiagSlot(maxPerRun)) return
+
+ const safe = label.replace(/[^a-z0-9-_]/gi, '_').slice(0, 64)
+ const now = new Date()
+ const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
+ const baseDir = `${process.cwd()}/reports/${day}`
+ const fs = await import('fs')
+ const path = await import('path')
+ if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
+ const ts = `${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}${String(now.getSeconds()).padStart(2,'0')}`
+ const shot = path.join(baseDir, `${ts}_${safe}.png`)
+ const htmlPath = path.join(baseDir, `${ts}_${safe}.html`)
+ if (cfg.saveScreenshot !== false) {
+ await page.screenshot({ path: shot }).catch(()=>{})
+ }
+ if (cfg.saveHtml !== false) {
+ const html = await page.content().catch(()=> '')
+ fs.writeFileSync(htmlPath, html)
+ }
+ this.bot.log(this.bot.isMobile, 'DIAG', `Saved diagnostics to ${shot} and ${htmlPath}`)
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'DIAG', `Failed to capture diagnostics: ${e instanceof Error ? e.message : e}`, 'warn')
+ }
+ }
+
}
\ No newline at end of file
diff --git a/src/config.json b/src/config.json
index daf334f..7f5ff4c 100644
--- a/src/config.json
+++ b/src/config.json
@@ -1,51 +1,197 @@
{
+ // Base URL for Rewards dashboard and APIs (do not change unless you know what you're doing)
"baseURL": "https://rewards.bing.com",
+ // Where to store sessions (cookies, fingerprints)
"sessionPath": "sessions",
- "headless": false,
- "parallel": false,
- "runOnZeroPoints": false,
- "clusters": 1,
- "saveFingerprint": {
- "mobile": false,
- "desktop": false
+
+ "browser": {
+ // Run browser without UI (true=headless, false=visible). Visible can help with stability.
+ "headless": false,
+ // Max time to wait for common operations (supports ms/s/min: e.g. 30000, "30s", "2min")
+ "globalTimeout": "30s"
},
+
+ "execution": {
+ // Run desktop+mobile in parallel (needs more resources). If false, runs sequentially.
+ "parallel": false,
+ // If false and there are 0 points available, the run is skipped early to save time.
+ "runOnZeroPoints": false,
+ // Number of account clusters (processes) to run concurrently.
+ "clusters": 1,
+ // Number of passes per invocation (advanced; usually 1).
+ "passesPerRun": 1
+ },
+
+ "buyMode": {
+ // Manual purchase/redeem mode. Use CLI -buy to enable, or set buyMode.enabled in config.
+ // Session duration cap in minutes.
+ "maxMinutes": 45
+ },
+
+ "fingerprinting": {
+ // Persist browser fingerprints per device type to improve consistency across runs
+ "saveFingerprint": {
+ "mobile": false,
+ "desktop": false
+ }
+ },
+
+ "search": {
+ // Use locale-specific query sources
+ "useLocalQueries": false,
+ "settings": {
+ // Add geo/locale signal into query selection
+ "useGeoLocaleQueries": false,
+ // Randomly scroll search result pages to look more natural
+ "scrollRandomResults": true,
+ // Occasionally click a result (safe targets only)
+ "clickRandomResults": true,
+ // Number of times to retry mobile searches if points didnโt progress
+ "retryMobileSearchAmount": 2,
+ // Delay between searches (supports numbers in ms or time strings)
+ "delay": {
+ "min": "3min",
+ "max": "5min"
+ }
+ }
+ },
+
+ "humanization": {
+ // Global Human Mode switch. true=adds subtle micro-gestures & pauses. false=classic behavior.
+ "enabled": true,
+ // If true, as soon as a ban is detected on any account, stop processing remaining accounts
+ // (ban detection is based on centralized heuristics and error signals)
+ "stopOnBan": true,
+ // If true, immediately send an alert (webhook/NTFY) when a ban is detected
+ "immediateBanAlert": true,
+ // Extra random pause between actions (ms or time string e.g., "300ms", "1s")
+ "actionDelay": {
+ "min": 150,
+ "max": 450
+ },
+ // Probability (0..1) to move mouse a tiny bit in between actions
+ "gestureMoveProb": 0.4,
+ // Probability (0..1) to perform a very small scroll
+ "gestureScrollProb": 0.2,
+ // Optional local-time windows for execution (e.g., ["08:30-11:00", "19:00-22:00"]).
+ // If provided, runs will wait until inside a window before starting.
+ "allowedWindows": []
+ },
+
+ // Optional monthly "vacation" block: skip a contiguous range of days to look more human.
+ // This is independent of weekly random off-days. When enabled, each month a random
+ // block between minDays and maxDays is selected (e.g., 3โ5 days) and all runs within
+ // that date range are skipped. The chosen block is logged at the start of the month.
+ "vacation": {
+ "enabled": false,
+ "minDays": 3,
+ "maxDays": 5
+ },
+
+ "retryPolicy": {
+ // Generic retry/backoff for transient failures
+ "maxAttempts": 3,
+ "baseDelay": 1000,
+ "maxDelay": "30s",
+ "multiplier": 2,
+ "jitter": 0.2
+ },
+
"workers": {
+ // Select what the bot should complete on desktop/mobile
"doDailySet": true,
"doMorePromotions": true,
"doPunchCards": true,
"doDesktopSearch": true,
"doMobileSearch": true,
"doDailyCheckIn": true,
- "doReadToEarn": true
+ "doReadToEarn": true,
+ // If true, run a desktop search bundle right after Daily Set
+ "bundleDailySetWithSearch": false
},
- "searchOnBingLocalQueries": false,
- "globalTimeout": "30s",
- "searchSettings": {
- "useGeoLocaleQueries": false,
- "scrollRandomResults": true,
- "clickRandomResults": true,
- "searchDelay": {
- "min": "3min",
- "max": "5min"
- },
- "retryMobileSearchAmount": 2
- },
- "logExcludeFunc": [
- "SEARCH-CLOSE-TABS"
- ],
- "webhookLogExcludeFunc": [
- "SEARCH-CLOSE-TABS"
- ],
+
"proxy": {
+ // Control which outbound calls go through your proxy
"proxyGoogleTrends": true,
"proxyBingTerms": true
},
- "webhook": {
- "enabled": false,
- "url": ""
+
+ "notifications": {
+ // Live logs (Discord or similar). URL is your webhook endpoint.
+ "webhook": {
+ "enabled": false,
+ "url": ""
+ },
+ // Rich end-of-run summary (Discord or similar)
+ "conclusionWebhook": {
+ "enabled": false,
+ "url": ""
+ },
+ // NTFY push notifications (plain text)
+ "ntfy": {
+ "enabled": false,
+ "url": "",
+ "topic": "rewards",
+ "authToken": ""
+ }
},
- "conclusionWebhook": {
+
+ "logging": {
+ // Logging controls (see docs/config.md). Remove redactEmails or set false to show full emails.
+ // Filter out noisy log buckets locally and for any webhook summaries
+ "excludeFunc": [
+ "SEARCH-CLOSE-TABS",
+ "LOGIN-NO-PROMPT",
+ "FLOW"
+ ],
+ "webhookExcludeFunc": [
+ "SEARCH-CLOSE-TABS",
+ "LOGIN-NO-PROMPT",
+ "FLOW"
+ ],
+ // Email redaction toggle (previously logging.live.redactEmails)
+ "redactEmails": true
+ },
+
+ "diagnostics": {
+ // Capture minimal evidence on failures (screenshots/HTML) and prune old runs
+ "enabled": true,
+ "saveScreenshot": true,
+ "saveHtml": true,
+ "maxPerRun": 2,
+ "retentionDays": 7
+ },
+
+
+
+ "jobState": {
+ // Checkpoint to avoid duplicate work across restarts
+ "enabled": true,
+ // Custom state directory (defaults to sessionPath/job-state if empty)
+ "dir": ""
+ },
+
+ "schedule": {
+ // Built-in scheduler (no cron needed in container). Uses the IANA time zone below.
"enabled": false,
- "url": ""
+ // Choose YOUR preferred time format:
+ // - US style with AM/PM โ set useAmPm: true and edit time12 (e.g., "9:00 AM")
+ // - 24-hour style โ set useAmPm: false and edit time24 (e.g., "09:00")
+ // Back-compat: if both time12/time24 are empty, the legacy "time" (HH:mm) will be used if present.
+ "useAmPm": false,
+ "time12": "9:00 AM",
+ "time24": "09:00",
+ // IANA timezone for scheduling (set to your region), e.g. "Europe/Paris" or "America/New_York"
+ "timeZone": "America/New_York",
+ // If true, run one pass immediately when the process starts
+ "runImmediatelyOnStart": false
+ },
+
+ "update": {
+ // Optional post-run auto-update
+ "git": true,
+ "docker": false,
+ // Custom updater script path (relative to repo root)
+ "scriptPath": "setup/update/update.mjs"
}
}
\ No newline at end of file
diff --git a/src/crontab.template b/src/crontab.template
deleted file mode 100644
index 5576966..0000000
--- a/src/crontab.template
+++ /dev/null
@@ -1,2 +0,0 @@
-# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs
-${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-script/src/run_daily.sh >> /proc/1/fd/1 2>&1
diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts
index 74dd166..0d3cf58 100644
--- a/src/functions/Activities.ts
+++ b/src/functions/Activities.ts
@@ -13,15 +13,109 @@ import { ReadToEarn } from './activities/ReadToEarn'
import { DailyCheckIn } from './activities/DailyCheckIn'
import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
+import type { ActivityHandler } from '../interface/ActivityHandler'
+
+type ActivityKind =
+ | { type: 'poll' }
+ | { type: 'abc' }
+ | { type: 'thisOrThat' }
+ | { type: 'quiz' }
+ | { type: 'urlReward' }
+ | { type: 'searchOnBing' }
+ | { type: 'unsupported' }
export default class Activities {
private bot: MicrosoftRewardsBot
+ private handlers: ActivityHandler[] = []
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
}
+ // Register external/custom handlers (optional extension point)
+ registerHandler(handler: ActivityHandler) {
+ this.handlers.push(handler)
+ }
+
+ // Centralized dispatcher for activities from dashboard/punchcards
+ async run(page: Page, activity: MorePromotion | PromotionalItem): Promise {
+ // First, try custom handlers (if any)
+ for (const h of this.handlers) {
+ try {
+ if (h.canHandle(activity)) {
+ await h.run(page, activity)
+ return
+ }
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'ACTIVITY', `Custom handler ${(h.id || 'unknown')} failed: ${e instanceof Error ? e.message : e}`, 'error')
+ }
+ }
+
+ const kind = this.classifyActivity(activity)
+ try {
+ switch (kind.type) {
+ case 'poll':
+ await this.doPoll(page)
+ break
+ case 'abc':
+ await this.doABC(page)
+ break
+ case 'thisOrThat':
+ await this.doThisOrThat(page)
+ break
+ case 'quiz':
+ await this.doQuiz(page)
+ break
+ case 'searchOnBing':
+ await this.doSearchOnBing(page, activity)
+ break
+ case 'urlReward':
+ await this.doUrlReward(page)
+ break
+ default:
+ this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${String((activity as { promotionType?: string }).promotionType)}"!`, 'warn')
+ break
+ }
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'ACTIVITY', `Dispatcher error for "${activity.title}": ${e instanceof Error ? e.message : e}`, 'error')
+ }
+ }
+
+ public getTypeLabel(activity: MorePromotion | PromotionalItem): string {
+ const k = this.classifyActivity(activity)
+ switch (k.type) {
+ case 'poll': return 'Poll'
+ case 'abc': return 'ABC'
+ case 'thisOrThat': return 'ThisOrThat'
+ case 'quiz': return 'Quiz'
+ case 'searchOnBing': return 'SearchOnBing'
+ case 'urlReward': return 'UrlReward'
+ default: return 'Unsupported'
+ }
+ }
+
+ private classifyActivity(activity: MorePromotion | PromotionalItem): ActivityKind {
+ const type = (activity.promotionType || '').toLowerCase()
+ if (type === 'quiz') {
+ // Distinguish Poll/ABC/ThisOrThat vs general quiz using current heuristics
+ const max = activity.pointProgressMax
+ const url = (activity.destinationUrl || '').toLowerCase()
+ if (max === 10) {
+ if (url.includes('pollscenarioid')) return { type: 'poll' }
+ return { type: 'abc' }
+ }
+ if (max === 50) return { type: 'thisOrThat' }
+ return { type: 'quiz' }
+ }
+ if (type === 'urlreward') {
+ const name = (activity.name || '').toLowerCase()
+ if (name.includes('exploreonbing')) return { type: 'searchOnBing' }
+ return { type: 'urlReward' }
+ }
+ return { type: 'unsupported' }
+ }
+
doSearch = async (page: Page, data: DashboardData): Promise => {
const search = new Search(this.bot)
await search.doSearch(page, data)
diff --git a/src/functions/Login.ts b/src/functions/Login.ts
index 2795b18..88093c0 100644
--- a/src/functions/Login.ts
+++ b/src/functions/Login.ts
@@ -1,546 +1,598 @@
+// Clean refactored Login implementation
+// Public API preserved: login(), getMobileAccessToken()
+
import type { Page } from 'playwright'
-import readline from 'readline'
import * as crypto from 'crypto'
+import fs from 'fs'
+import path from 'path'
+import readline from 'readline'
import { AxiosRequestConfig } from 'axios'
-
-import { MicrosoftRewardsBot } from '../index'
+import { generateTOTP } from '../util/Totp'
import { saveSessionData } from '../util/Load'
-
+import { MicrosoftRewardsBot } from '../index'
import { OAuth } from '../interface/OAuth'
+// -------------------------------
+// Constants / Tunables
+// -------------------------------
+const SELECTORS = {
+ emailInput: 'input[type="email"]',
+ passwordInput: 'input[type="password"]',
+ submitBtn: 'button[type="submit"]',
+ passkeySecondary: 'button[data-testid="secondaryButton"]',
+ passkeyPrimary: 'button[data-testid="primaryButton"]',
+ passkeyTitle: '[data-testid="title"]',
+ kmsiVideo: '[data-testid="kmsiVideo"]',
+ biometricVideo: '[data-testid="biometricVideo"]'
+} as const
-const rl = readline.createInterface({
- // Use as any to avoid strict typing issues with our minimal process shim
- input: (process as any).stdin,
- output: (process as any).stdout
-})
+const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
+
+const DEFAULT_TIMEOUTS = {
+ loginMaxMs: Number(process.env.LOGIN_MAX_WAIT_MS || 180000), // 3 min
+ short: 500,
+ medium: 1500,
+ long: 3000
+}
+
+// Security pattern bundle
+const SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [
+ { re: /we can['โ`]?t sign you in/i, label: 'cant-sign-in' },
+ { re: /incorrect account or password too many times/i, label: 'too-many-incorrect' },
+ { re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' },
+ { re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' },
+ { re: /your account has been locked/i, label: 'account-locked' },
+ { re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' }
+]
+
+interface SecurityIncident {
+ kind: string
+ account: string
+ details?: string[]
+ next?: string[]
+ docsUrl?: string
+}
export class Login {
- private bot: MicrosoftRewardsBot
- private clientId: string = '0000000040170455'
- private authBaseUrl: string = 'https://login.live.com/oauth20_authorize.srf'
- private redirectUrl: string = 'https://login.live.com/oauth20_desktop.srf'
- private tokenUrl: string = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
- private scope: string = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
- // Flag to prevent spamming passkey logs after first handling
- private passkeyHandled: boolean = false
+ private bot: MicrosoftRewardsBot
+ private clientId = '0000000040170455'
+ private authBaseUrl = 'https://login.live.com/oauth20_authorize.srf'
+ private redirectUrl = 'https://login.live.com/oauth20_desktop.srf'
+ private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
+ private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
- constructor(bot: MicrosoftRewardsBot) {
- this.bot = bot
+ private currentTotpSecret?: string
+ private compromisedInterval?: NodeJS.Timeout
+ private passkeyHandled = false
+ private noPromptIterations = 0
+ private lastNoPromptLog = 0
+
+ constructor(bot: MicrosoftRewardsBot) { this.bot = bot }
+
+ // --------------- Public API ---------------
+ async login(page: Page, email: string, password: string, totpSecret?: string) {
+ try {
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
+ this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
+
+ await page.goto('https://rewards.bing.com/signin')
+ await this.disableFido(page)
+ 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)
+ if (!already) {
+ await this.performLoginFlow(page, email, password)
+ } else {
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Session already authenticated')
+ await this.checkAccountLocked(page)
+ }
+
+ await this.verifyBingContext(page)
+ await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile)
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Login complete (session saved)')
+ this.currentTotpSecret = undefined
+ } catch (e) {
+ throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed login: ' + e, 'error')
+ }
+ }
+
+ async getMobileAccessToken(page: Page, email: string) {
+ // Reuse same FIDO disabling
+ await this.disableFido(page)
+ const url = new URL(this.authBaseUrl)
+ url.searchParams.set('response_type', 'code')
+ url.searchParams.set('client_id', this.clientId)
+ url.searchParams.set('redirect_uri', this.redirectUrl)
+ url.searchParams.set('scope', this.scope)
+ url.searchParams.set('state', crypto.randomBytes(16).toString('hex'))
+ url.searchParams.set('access_type', 'offline_access')
+ url.searchParams.set('login_hint', email)
+
+ await page.goto(url.href)
+ const start = Date.now()
+ this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Authorizing mobile scope...')
+ let code = ''
+ while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
+ await this.handlePasskeyPrompts(page, 'oauth')
+ const u = new URL(page.url())
+ if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') {
+ code = u.searchParams.get('code') || ''
+ break
+ }
+ await this.bot.utils.wait(1000)
+ }
+ if (!code) throw this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'OAuth code not received in time', 'error')
+
+ const form = new URLSearchParams()
+ form.append('grant_type', 'authorization_code')
+ form.append('client_id', this.clientId)
+ form.append('code', code)
+ form.append('redirect_uri', this.redirectUrl)
+
+ 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`)
+ return data.access_token
+ }
+
+ // --------------- Main Flow ---------------
+ private async performLoginFlow(page: Page, email: string, password: string) {
+ await this.inputEmail(page, email)
+ await this.bot.utils.wait(1000)
+ await this.bot.browser.utils.reloadBadPage(page)
+ 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')
+ return
+ }
+ // Try switching to password if a locale link is present (FR/EN)
+ await this.switchToPasswordLink(page)
+ await this.inputPasswordOr2FA(page, password)
+ if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') {
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected โ halting.', 'warn')
+ return
+ }
+ await this.checkAccountLocked(page)
+ await this.awaitRewardsPortal(page)
+ }
+
+ // --------------- Input Steps ---------------
+ private async inputEmail(page: Page, email: string) {
+ 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)
+ 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') }
+ }
+
+ 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) }
+
+ // Rare flow: list of methods -> choose password
+ const passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(()=>null)
+ if (!passwordField) {
+ const blocked = await this.detectSignInBlocked(page)
+ if (blocked) return
+ // If still no password field -> likely 2FA (approvals) first
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field absent โ invoking 2FA handler', 'warn')
+ await this.handle2FA(page)
+ return
}
- async login(page: Page, email: string, password: string) {
+ const blocked = await this.detectSignInBlocked(page)
+ if (blocked) return
- try {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process!')
+ 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') }
+ }
- // Navigate to the Bing login page
- await page.goto('https://rewards.bing.com/signin')
+ // --------------- 2FA Handling ---------------
+ private async handle2FA(page: Page) {
+ try {
+ const number = await this.fetchAuthenticatorNumber(page)
+ if (number) { await this.approveAuthenticator(page, number); return }
+ await this.handleSMSOrTotp(page)
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'LOGIN', '2FA error: ' + e, 'warn')
+ }
+ }
- // Disable FIDO support in login request
- await page.route('**/GetCredentialType.srf*', (route: any) => {
- const body = JSON.parse(route.request().postData() || '{}')
- body.isFidoSupported = false
- route.continue({ postData: JSON.stringify(body) })
- })
-
- await page.waitForLoadState('domcontentloaded').catch(() => { })
-
- await this.bot.browser.utils.reloadBadPage(page)
-
- // Check if account is locked
- await this.checkAccountLocked(page)
-
- const isLoggedIn = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 10000 }).then(() => true).catch(() => false)
-
- if (!isLoggedIn) {
- await this.execLogin(page, email, password)
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft successfully')
- } else {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Already logged in')
-
- // Check if account is locked
- await this.checkAccountLocked(page)
- }
-
- // Check if logged in to bing
- await this.checkBingLogin(page)
-
- // Save session
- await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile)
-
- // We're done logging in
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Logged in successfully, saved login session!')
-
- } catch (error) {
- // Throw and don't continue
- throw this.bot.log(this.bot.isMobile, 'LOGIN', 'An error occurred:' + error, 'error')
+ private async fetchAuthenticatorNumber(page: Page): Promise {
+ try {
+ const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2500 })
+ return (await el.textContent())?.trim() || null
+ } catch {
+ // Attempt resend loop in parallel mode
+ 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)
+ if (!resend) break
+ await this.bot.utils.wait(60000)
+ await resend.click().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 })
+ return (await el.textContent())?.trim() || null
+ } catch { return null }
}
+ }
- private async execLogin(page: Page, email: string, password: string) {
- try {
- await this.enterEmail(page, email)
- await this.bot.utils.wait(2000)
- await this.bot.browser.utils.reloadBadPage(page)
- await this.bot.utils.wait(2000)
- await this.enterPassword(page, password)
- await this.bot.utils.wait(2000)
-
- // Check if account is locked
- await this.checkAccountLocked(page)
-
- await this.bot.browser.utils.reloadBadPage(page)
- await this.checkLoggedIn(page)
- } catch (error) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'An error occurred: ' + error, 'error')
- }
+ private async approveAuthenticator(page: Page, numberToPress: string) {
+ for (let cycle = 0; cycle < 6; cycle++) { // max ~6 refresh cycles
+ try {
+ this.bot.log(this.bot.isMobile, 'LOGIN', `Approve login in Authenticator (press ${numberToPress})`)
+ await page.waitForSelector('form[name="f1"]', { state: 'detached', timeout: 60000 })
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval successful')
+ 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 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')
+ }
- private async enterEmail(page: Page, email: string) {
- const emailInputSelector = 'input[type="email"]'
-
- try {
- // Wait for email field
- const emailField = await page.waitForSelector(emailInputSelector, { state: 'visible', timeout: 2000 }).catch(() => null)
- if (!emailField) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not found', 'warn')
- return
- }
-
- await this.bot.utils.wait(1000)
-
- // Check if email is prefilled
- const emailPrefilled = await page.waitForSelector('#userDisplayName', { timeout: 5000 }).catch(() => null)
- if (emailPrefilled) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Email already prefilled by Microsoft')
- } else {
- // Else clear and fill email
- await page.fill(emailInputSelector, '')
- await this.bot.utils.wait(500)
- await page.fill(emailInputSelector, email)
- await this.bot.utils.wait(1000)
- }
-
- const nextButton = await page.waitForSelector('button[type="submit"]', { timeout: 2000 }).catch(() => null)
- if (nextButton) {
- await nextButton.click()
- await this.bot.utils.wait(2000)
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Email entered successfully')
- } else {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Next button not found after email entry', 'warn')
- }
-
- } catch (error) {
- this.bot.log(this.bot.isMobile, 'LOGIN', `Email entry failed: ${error}`, 'error')
- }
- }
-
- private async enterPassword(page: Page, password: string) {
- const passwordInputSelector = 'input[type="password"]'
- const skip2FASelector = '#idA_PWD_SwitchToPassword';
- try {
- const skip2FAButton = await page.waitForSelector(skip2FASelector, { timeout: 2000 }).catch(() => null)
- if (skip2FAButton) {
- await skip2FAButton.click()
- await this.bot.utils.wait(2000)
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipped 2FA')
- } else {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'No 2FA skip button found, proceeding with password entry')
- }
- const viewFooter = await page.waitForSelector('#view > div > span:nth-child(6)', { timeout: 2000 }).catch(() => null)
- const passwordField1 = await page.waitForSelector(passwordInputSelector, { timeout: 5000 }).catch(() => null)
- if (viewFooter && !passwordField1) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Page "Get a code to sign in" found by "viewFooter"')
-
- const otherWaysButton = await viewFooter.$('span[role="button"]')
- if (otherWaysButton) {
- await otherWaysButton.click()
- await this.bot.utils.wait(5000)
-
- const secondListItem = page.locator('[role="listitem"]').nth(1)
- if (await secondListItem.isVisible()) {
- await secondListItem.click()
- }
- }
- }
-
- // Wait for password field
- const passwordField = await page.waitForSelector(passwordInputSelector, { state: 'visible', timeout: 5000 }).catch(() => null)
- if (!passwordField) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field not found, possibly 2FA required', 'warn')
- await this.handle2FA(page)
- return
- }
-
- await this.bot.utils.wait(1000)
-
- // Clear and fill password
- await page.fill(passwordInputSelector, '')
- await this.bot.utils.wait(500)
- await page.fill(passwordInputSelector, password)
- await this.bot.utils.wait(1000)
-
- const nextButton = await page.waitForSelector('button[type="submit"]', { timeout: 2000 }).catch(() => null)
- if (nextButton) {
- await nextButton.click()
- await this.bot.utils.wait(2000)
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Password entered successfully')
- } else {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Next button not found after password entry', 'warn')
- }
-
- } catch (error) {
- this.bot.log(this.bot.isMobile, 'LOGIN', `Password entry failed: ${error}`, 'error')
- await this.handle2FA(page)
- }
- }
-
- private async handle2FA(page: Page) {
- try {
- const numberToPress = await this.get2FACode(page)
- if (numberToPress) {
- // Authenticator App verification
- await this.authAppVerification(page, numberToPress)
- } else {
- // SMS verification
- await this.authSMSVerification(page)
- }
- } catch (error) {
- this.bot.log(this.bot.isMobile, 'LOGIN', `2FA handling failed: ${error}`)
- }
- }
-
- private async get2FACode(page: Page): Promise {
- try {
- const element = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { state: 'visible', timeout: 2000 })
- return await element.textContent()
- } catch {
- if (this.bot.config.parallel) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Script running in parallel, can only send 1 2FA request per account at a time!', 'log', 'yellow')
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Trying again in 60 seconds! Please wait...', 'log', 'yellow')
-
- // eslint-disable-next-line no-constant-condition
- while (true) {
- const button = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { state: 'visible', timeout: 2000 }).catch(() => null)
- if (button) {
- await this.bot.utils.wait(60000)
- await button.click()
-
- continue
- } else {
- break
- }
- }
- }
-
- await page.click('button[aria-describedby="confirmSendTitle"]').catch(() => { })
- await this.bot.utils.wait(2000)
- const element = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { state: 'visible', timeout: 2000 })
- return await element.textContent()
- }
- }
-
- private async authAppVerification(page: Page, numberToPress: string | null) {
- // eslint-disable-next-line no-constant-condition
- while (true) {
- try {
- this.bot.log(this.bot.isMobile, 'LOGIN', `Press the number ${numberToPress} on your Authenticator app to approve the login`)
- this.bot.log(this.bot.isMobile, 'LOGIN', 'If you press the wrong number or the "DENY" button, try again in 60 seconds')
-
- await page.waitForSelector('form[name="f1"]', { state: 'detached', timeout: 60000 })
-
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Login successfully approved!')
- break
- } catch {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'The code is expired. Trying to get a new code...')
- // await page.click('button[aria-describedby="pushNotificationsTitle errorDescription"]')
- const primaryButton = await page.waitForSelector('button[data-testid="primaryButton"]', { state: 'visible', timeout: 5000 }).catch(() => null)
- if (primaryButton) {
- await primaryButton.click()
- }
- numberToPress = await this.get2FACode(page)
- }
- }
- }
-
- private async authSMSVerification(page: Page) {
- this.bot.log(this.bot.isMobile, 'LOGIN', 'SMS 2FA code required. Waiting for user input...')
-
- const code = await new Promise((resolve) => {
- rl.question('Enter 2FA code:\n', (input: string) => {
- rl.close()
- resolve(input)
- })
- })
-
+ private async handleSMSOrTotp(page: Page) {
+ // TOTP auto entry
+ try {
+ if (this.currentTotpSecret) {
+ const code = generateTOTP(this.currentTotpSecret.trim())
await page.fill('input[name="otc"]', code)
await page.keyboard.press('Enter')
- this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code entered successfully')
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
+ return
+ }
+ } catch {/* ignore */}
+
+ // Manual prompt
+ 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 })
+ const code: string = await new Promise(res => rl.question('Enter 2FA code:\n', ans => { rl.close(); res(ans.trim()) }))
+ await page.fill('input[name="otc"]', code)
+ await page.keyboard.press('Enter')
+ this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
+ }
+
+ // --------------- Verification / State ---------------
+ private async awaitRewardsPortal(page: Page) {
+ const start = Date.now()
+ while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
+ await this.handlePasskeyPrompts(page, 'main')
+ const u = new URL(page.url())
+ if (u.hostname === LOGIN_TARGET.host && u.pathname === LOGIN_TARGET.path) break
+ await this.bot.utils.wait(1000)
+ }
+ const portal = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).catch(()=>null)
+ if (!portal) throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal root element missing after navigation', 'error')
+ this.bot.log(this.bot.isMobile, 'LOGIN', 'Reached rewards portal')
+ }
+
+ private async verifyBingContext(page: Page) {
+ 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++) {
+ 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 }
+ }
+ await this.bot.utils.wait(1000)
+ }
+ } catch (e) {
+ 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')
+ }
+
+ // --------------- 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)
+ if (biometric) {
+ const btn = await page.$(SELECTORS.passkeySecondary)
+ 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 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) }
+ 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') }
+ }
+ 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 (!did) {
+ const close = await page.$('#close-button')
+ if (close) { await close.click().catch(()=>{}); did = true; this.logPasskeyOnce('close button') }
+ }
}
- async getMobileAccessToken(page: Page, email: string) {
- const authorizeUrl = new URL(this.authBaseUrl)
-
- authorizeUrl.searchParams.append('response_type', 'code')
- authorizeUrl.searchParams.append('client_id', this.clientId)
- authorizeUrl.searchParams.append('redirect_uri', this.redirectUrl)
- authorizeUrl.searchParams.append('scope', this.scope)
- authorizeUrl.searchParams.append('state', crypto.randomBytes(16).toString('hex'))
- authorizeUrl.searchParams.append('access_type', 'offline_access')
- authorizeUrl.searchParams.append('login_hint', email)
-
- // Disable FIDO for OAuth flow as well (reduces passkey prompts resurfacing)
- await page.route('**/GetCredentialType.srf*', (route: any) => {
- const body = JSON.parse(route.request().postData() || '{}')
- body.isFidoSupported = false
- route.continue({ postData: JSON.stringify(body) })
- }).catch(()=>{})
-
- await page.goto(authorizeUrl.href)
-
- let currentUrl = new URL(page.url())
- let code: string
-
- const authStart = Date.now()
- this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Waiting for authorization...')
- // eslint-disable-next-line no-constant-condition
- while (true) {
- // Attempt to dismiss passkey/passkey-like screens quickly (non-blocking)
- await this.tryDismissPasskeyPrompt(page)
- if (currentUrl.hostname === 'login.live.com' && currentUrl.pathname === '/oauth20_desktop.srf') {
- code = currentUrl.searchParams.get('code')!
- break
- }
-
- currentUrl = new URL(page.url())
- // Shorter wait to react faster to passkey prompt
- await this.bot.utils.wait(1000)
- }
-
- const body = new URLSearchParams()
- body.append('grant_type', 'authorization_code')
- body.append('client_id', this.clientId)
- body.append('code', code)
- body.append('redirect_uri', this.redirectUrl)
-
- const tokenRequest: AxiosRequestConfig = {
- url: this.tokenUrl,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- data: body.toString()
- }
-
- const tokenResponse = await this.bot.axios.request(tokenRequest)
- const tokenData: OAuth = await tokenResponse.data
-
- const authDuration = Date.now() - authStart
- this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Successfully authorized in ${Math.round(authDuration/1000)}s`)
- return tokenData.access_token
+ // KMSI prompt
+ 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') }
}
- // Utils
-
- private async checkLoggedIn(page: Page) {
- const targetHostname = 'rewards.bing.com'
- const targetPathname = '/'
-
- // eslint-disable-next-line no-constant-condition
- while (true) {
- await this.dismissLoginMessages(page)
- const currentURL = new URL(page.url())
- if (currentURL.hostname === targetHostname && currentURL.pathname === targetPathname) {
- break
- }
- }
-
- // Wait for login to complete
- await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 10000 })
- this.bot.log(this.bot.isMobile, 'LOGIN', 'Successfully logged into the rewards portal')
+ if (!did && context === 'main') {
+ this.noPromptIterations++
+ 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})`)
+ if (this.noPromptIterations > 50) this.noPromptIterations = 0
+ }
+ } else if (did) {
+ this.noPromptIterations = 0
}
+ }
- private lastNoPromptLog: number = 0
- private noPromptIterations: number = 0
- private async dismissLoginMessages(page: Page) {
- let didSomething = false
+ private logPasskeyOnce(reason: string) {
+ if (this.passkeyHandled) return
+ this.passkeyHandled = true
+ this.bot.log(this.bot.isMobile,'LOGIN-PASSKEY',`Dismissed passkey prompt (${reason})`)
+ }
- // PASSKEY / Windows Hello / Sign in faster
- const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 1000 }).catch(() => null)
- if (passkeyVideo) {
- const skipButton = await page.$('button[data-testid="secondaryButton"]')
- if (skipButton) {
- await skipButton.click().catch(()=>{})
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog detected (video heuristic) -> clicked "Skip for now"')
- }
- this.passkeyHandled = true
- await page.waitForTimeout(300)
- didSomething = true
- }
- }
- if (!didSomething) {
- const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 800 }).catch(() => null)
- const titleText = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
- const looksLikePasskey = /sign in faster|passkey|fingerprint|face|pin/i.test(titleText)
- const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
- const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 500 }).catch(() => null)
- if (looksLikePasskey && secondaryBtn) {
- await secondaryBtn.click().catch(()=>{})
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey dialog detected (title: "${titleText}") -> clicked secondary`)
- }
- this.passkeyHandled = true
- await page.waitForTimeout(300)
- didSomething = true
- } else if (!didSomething && secondaryBtn && primaryBtn) {
- const secText = (await secondaryBtn.textContent() || '').trim()
- if (/skip for now/i.test(secText)) {
- await secondaryBtn.click().catch(()=>{})
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (pair heuristic) -> clicked secondary (Skip for now)')
- }
- this.passkeyHandled = true
- await page.waitForTimeout(300)
- didSomething = true
- }
- }
- if (!didSomething) {
- const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first()
- if (await skipByText.isVisible().catch(()=>false)) {
- await skipByText.click().catch(()=>{})
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (text fallback) -> clicked "Skip for now"')
- }
- this.passkeyHandled = true
- await page.waitForTimeout(300)
- didSomething = true
- }
- }
- if (!didSomething) {
- const closeBtn = await page.$('#close-button')
- if (closeBtn) {
- await closeBtn.click().catch(()=>{})
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Attempted close button on potential passkey modal')
- }
- this.passkeyHandled = true
- await page.waitForTimeout(300)
- }
- }
+ // --------------- Security Detection ---------------
+ private async detectSignInBlocked(page: Page): Promise {
+ 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)
+ if (el) {
+ const t = (await el.textContent()||'').trim()
+ if (t && t.length < 300) text += ' '+t
}
+ }
+ const lower = text.toLowerCase()
+ let matched: string | null = null
+ 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
+ }
+ 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(()=>{})
+ await this.saveIncidentArtifacts(page,'sign-in-blocked').catch(()=>{})
+ // Open security docs for immediate guidance (best-effort)
+ await this.openDocsTab(page, docsUrl).catch(()=>{})
+ return true
+ } catch { return false }
+ }
- // KMSI (Keep me signed in) prompt
- const kmsi = await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 800 }).catch(()=>null)
- if (kmsi) {
- const yesButton = await page.$('button[data-testid="primaryButton"]')
- if (yesButton) {
- await yesButton.click().catch(()=>{})
- this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'KMSI dialog detected -> accepted (Yes)')
- await page.waitForTimeout(300)
- didSomething = true
- }
- }
+ 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)
+ if (refs.length === 0) return
- if (!didSomething) {
- this.noPromptIterations++
- 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})`)
- // Reset counter if it grows large to keep number meaningful
- if (this.noPromptIterations > 50) this.noPromptIterations = 0
- }
- } else {
- // Reset counters after an interaction
- this.noPromptIterations = 0
- }
- }
+ 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) }
- /** Lightweight passkey prompt dismissal used in mobile OAuth loop */
- private async tryDismissPasskeyPrompt(page: Page) {
+ // 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) }
+
+ // 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) }
+
+ // 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))
+ // Masked filter
+ let masked = uniq.filter(t=>/@/.test(t) && /[*โข]/.test(t))
+
+ if (masked.length === 0) {
+ // Fallback full HTML scan
try {
- // Fast existence checks with very small timeouts to avoid slowing the loop
- const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 500 }).catch(() => null)
- const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
- // Direct text locator fallback (sometimes data-testid changes)
- const textSkip = secondaryBtn ? null : await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first().isVisible().catch(()=>false)
- if (secondaryBtn) {
- // Heuristic: if title indicates passkey or both primary/secondary exist with typical text
- let shouldClick = false
- let titleText = ''
- if (titleEl) {
- titleText = (await titleEl.textContent() || '').trim()
- if (/sign in faster|passkey|fingerprint|face|pin/i.test(titleText)) {
- shouldClick = true
- }
- }
- if (!shouldClick && textSkip) {
- shouldClick = true
- }
- if (!shouldClick) {
- // Fallback text probe on the secondary button itself
- const btnText = (await secondaryBtn.textContent() || '').trim()
- if (/skip for now/i.test(btnText)) {
- shouldClick = true
- }
- }
- if (shouldClick) {
- await secondaryBtn.click().catch(() => { })
- if (!this.passkeyHandled) {
- this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey prompt (loop) -> clicked skip${titleText ? ` (title: ${titleText})` : ''}`)
- }
- this.passkeyHandled = true
- await this.bot.utils.wait(500)
- }
- }
- } catch { /* ignore minor errors */ }
- }
+ const html = await page.content()
+ const generic = /[A-Za-z0-9]{1,4}[*โข]{2,}[A-Za-z0-9*โข._-]*@[A-Za-z0-9.-]+/g
+ const frPhrase = /Nous\s+enverrons\s+un\s+code\s+ร \s+([^<@]*[A-Za-z0-9]{1,4}[*โข]{2,}[A-Za-z0-9*โข._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vรฉrifier/gi
+ 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) }
+ if (found.size > 0) masked = Array.from(found)
+ } catch {/* ignore */}
+ }
+ if (masked.length === 0) return
- private async checkBingLogin(page: Page): Promise {
- try {
- this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing login')
- 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')
+ // Prefer one mentioning email/adresse
+ 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.
+ const maskRegex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*โข._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/
+ const m = maskRegex.exec(preferred)
+ // Fallback: try to salvage with looser pattern if first regex fails
+ const loose = !m ? /([a-zA-Z0-9])[*โข][a-zA-Z0-9*โข._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(preferred) : null
+ 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()
+ 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)
+ }
- const maxIterations = 5
-
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
- const currentUrl = new URL(page.url())
-
- if (currentUrl.hostname === 'www.bing.com' && currentUrl.pathname === '/') {
- await this.bot.browser.utils.tryDismissAllMessages(page)
-
- const loggedIn = await this.checkBingLoginStatus(page)
- // If mobile browser, skip this step
- if (loggedIn || this.bot.isMobile) {
- this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing login verification passed!')
- break
- }
- }
-
- await this.bot.utils.wait(1000)
- }
-
- } catch (error) {
- this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'An error occurred:' + error, 'error')
+ // Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic
+ const matchRef = refs.find(r => {
+ if (r.domain !== observedDomain) return false
+ // If only one char visible, only enforce first char; if two, enforce both.
+ if (observedPrefix.length === 1) {
+ return r.prefix2.startsWith(observedPrefix)
}
- }
+ return r.prefix2 === observedPrefix
+ })
- private async checkBingLoginStatus(page: Page): Promise {
- try {
- await page.waitForSelector('#id_n', { timeout: 5000 })
- return true
- } catch (error) {
- return false
+ if (!matchRef) {
+ const docsUrl = this.getDocsUrl('recovery-email-mismatch')
+ const incident: SecurityIncident = {
+ kind:'Recovery email mismatch',
+ account: email,
+ details:[
+ `MaskedShown: ${preferred}`,
+ `Extracted: ${extracted}`,
+ `Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`,
+ `Expected => ${refs.map(r=>`${r.prefix2}**@${r.domain}`).join(' OR ')}`
+ ],
+ 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')
+ this.bot.compromisedModeActive = true
+ this.bot.compromisedReason = 'recovery-mismatch'
+ this.startCompromisedInterval()
+ await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(()=>{})
+ await this.saveIncidentArtifacts(page,'recovery-mismatch').catch(()=>{})
+ await this.openDocsTab(page, docsUrl).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}`)
+ }
+ } catch {/* non-fatal */}
+ }
- private async checkAccountLocked(page: Page) {
- await this.bot.utils.wait(2000)
- const isLocked = await page.waitForSelector('#serviceAbuseLandingTitle', { state: 'visible', timeout: 1000 }).then(() => true).catch(() => false)
- if (isLocked) {
- throw this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'This account has been locked! Remove the account from "accounts.json" and restart!', 'error')
- }
+ 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(()=>{})
+ await this.bot.utils.wait(800)
+ this.bot.log(this.bot.isMobile,'LOGIN','Clicked "Use your password" link')
+ }
+ } catch {/* ignore */}
+ }
+
+ // --------------- Incident Helpers ---------------
+ 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)
+ try {
+ const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
+ await ConclusionWebhook(this.bot.config,'', { embeds:[{ title:`๐ ${incident.kind}`, description:'Security check by @Light', color: severity==='critical'?0xFF0000:0xFFAA00, fields:[
+ { name:'Account', value: incident.account },
+ ...(incident.details?.length?[{ name:'Details', value: incident.details.join('\n') }]:[]),
+ ...(incident.next?.length?[{ name:'Next steps', value: incident.next.join('\n') }]:[]),
+ ...(incident.docsUrl?[{ name:'Docs', value: incident.docsUrl }]:[])
+ ] }] })
+ } catch {/* ignore */}
+ }
+
+ 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
+ }
+
+ 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 saveIncidentArtifacts(page: Page, slug: string) {
+ try {
+ const base = path.join(process.cwd(),'diagnostics','security-incidents')
+ await fs.promises.mkdir(base,{ recursive:true })
+ const ts = new Date().toISOString().replace(/[:.]/g,'-')
+ const dir = path.join(base, `${ts}-${slug}`)
+ await fs.promises.mkdir(dir,{ recursive:true })
+ try { await page.screenshot({ path: path.join(dir,'page.png'), fullPage:false }) } catch {/* ignore */}
+ try { const html = await page.content(); await fs.promises.writeFile(path.join(dir,'page.html'), html) } catch {/* ignore */}
+ this.bot.log(this.bot.isMobile,'SECURITY',`Saved incident artifacts: ${dir}`)
+ } catch {/* ignore */}
+ }
+
+ 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 */}
+ }
+
+ // --------------- Infrastructure ---------------
+ private async disableFido(page: Page) {
+ await page.route('**/GetCredentialType.srf*', route => {
+ try {
+ const body = JSON.parse(route.request().postData() || '{}')
+ body.isFidoSupported = false
+ route.continue({ postData: JSON.stringify(body) })
+ } catch { route.continue() }
+ }).catch(()=>{})
+ }
}
diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts
index efe5da5..c9ff794 100644
--- a/src/functions/Workers.ts
+++ b/src/functions/Workers.ts
@@ -3,19 +3,30 @@ import { Page } from 'rebrowser-playwright'
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
import { MicrosoftRewardsBot } from '../index'
+import JobState from '../util/JobState'
+import Retry from '../util/Retry'
+import { AdaptiveThrottler } from '../util/AdaptiveThrottler'
export class Workers {
public bot: MicrosoftRewardsBot
+ private jobState: JobState
constructor(bot: MicrosoftRewardsBot) {
this.bot = bot
+ this.jobState = new JobState(this.bot.config)
}
// Daily Set
async doDailySet(page: Page, data: DashboardData) {
const todayData = data.dailySetPromotions[this.bot.utils.getFormattedDate()]
- const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
+ const today = this.bot.utils.getFormattedDate()
+ const activitiesUncompleted = (todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [])
+ .filter(x => {
+ if (this.bot.config.jobState?.enabled === false) return true
+ const email = this.bot.currentAccountEmail || 'unknown'
+ return !this.jobState.isDone(email, today, x.offerId)
+ })
if (!activitiesUncompleted.length) {
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All Daily Set" items have already been completed')
@@ -27,12 +38,30 @@ export class Workers {
await this.solveActivities(page, activitiesUncompleted)
+ // Mark as done to prevent duplicate work if checkpoints enabled
+ if (this.bot.config.jobState?.enabled !== false) {
+ const email = this.bot.currentAccountEmail || 'unknown'
+ for (const a of activitiesUncompleted) {
+ this.jobState.markDone(email, today, a.offerId)
+ }
+ }
+
page = await this.bot.browser.utils.getLatestTab(page)
// Always return to the homepage if not already
await this.bot.browser.func.goHome(page)
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
+
+ // Optional: immediately run desktop search bundle
+ if (!this.bot.isMobile && this.bot.config.workers.bundleDailySetWithSearch && this.bot.config.workers.doDesktopSearch) {
+ try {
+ await this.bot.utils.waitRandom(1200, 2600)
+ await this.bot.activities.doSearch(page, data)
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'DAILY-SET', `Post-DailySet search failed: ${e instanceof Error ? e.message : e}`, 'warn')
+ }
+ }
}
// Punch Card
@@ -120,7 +149,9 @@ export class Workers {
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
const activityInitial = activityPage.url() // Homepage for Daily/More and Index for promotions
- for (const activity of activities) {
+ const retry = new Retry(this.bot.config.retryPolicy)
+ const throttle = new AdaptiveThrottler()
+ for (const activity of activities) {
try {
// Reselect the worker page
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
@@ -132,7 +163,11 @@ export class Workers {
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
}
- await this.bot.utils.wait(1000)
+ await this.bot.browser.utils.humanizePage(activityPage)
+ {
+ const m = throttle.getDelayMultiplier()
+ await this.bot.utils.waitRandom(Math.floor(800*m), Math.floor(1400*m))
+ }
if (activityPage.url() !== activityInitial) {
await activityPage.goto(activityInitial)
@@ -154,74 +189,50 @@ export class Workers {
if it didn't then it gave enough time for the page to load.
*/
await activityPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { })
- await this.bot.utils.wait(2000)
-
- switch (activity.promotionType) {
- // Quiz (Poll, Quiz or ABC)
- case 'quiz':
- switch (activity.pointProgressMax) {
- // Poll or ABC (Usually 10 points)
- case 10:
- // Normal poll
- if (activity.destinationUrl.toLowerCase().includes('pollscenarioid')) {
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "Poll" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doPoll(activityPage)
- } else { // ABC
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "ABC" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doABC(activityPage)
- }
- break
-
- // This Or That Quiz (Usually 50 points)
- case 50:
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "ThisOrThat" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doThisOrThat(activityPage)
- break
-
- // Quizzes are usually 30-40 points
- default:
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "Quiz" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doQuiz(activityPage)
- break
- }
- break
-
- // UrlReward (Visit)
- case 'urlreward':
- // Search on Bing are subtypes of "urlreward"
- if (activity.name.toLowerCase().includes('exploreonbing')) {
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "SearchOnBing" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doSearchOnBing(activityPage, activity)
-
- } else {
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "UrlReward" title: "${activity.title}"`)
- await activityPage.click(selector)
- activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
- await this.bot.activities.doUrlReward(activityPage)
- }
- break
-
- // Unsupported types
- default:
- this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
- break
+ // Small human-like jitter before executing
+ await this.bot.browser.utils.humanizePage(activityPage)
+ {
+ const m = throttle.getDelayMultiplier()
+ await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
}
- // Cooldown
- await this.bot.utils.wait(2000)
+ // Log the detected type using the same heuristics as before
+ const typeLabel = this.bot.activities.getTypeLabel(activity)
+ if (typeLabel !== 'Unsupported') {
+ this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${typeLabel}" title: "${activity.title}"`)
+ await activityPage.click(selector)
+ activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
+ // Watchdog: abort if the activity hangs too long
+ const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
+ const runWithTimeout = (p: Promise) => Promise.race([
+ p,
+ new Promise((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
+ ])
+ await retry.run(async () => {
+ try {
+ await runWithTimeout(this.bot.activities.run(activityPage, activity))
+ throttle.record(true)
+ } catch (e) {
+ await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_timeout_${activity.title || activity.offerId}`)
+ throttle.record(false)
+ throw e
+ }
+ }, () => true)
+ } else {
+ this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
+ }
+
+ // Cooldown with jitter
+ await this.bot.browser.utils.humanizePage(activityPage)
+ {
+ const m = throttle.getDelayMultiplier()
+ await this.bot.utils.waitRandom(Math.floor(1200*m), Math.floor(2600*m))
+ }
} catch (error) {
+ await this.bot.browser.utils.captureDiagnostics(activityPage, `activity_error_${activity.title || activity.offerId}`)
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
+ throttle.record(false)
}
}
diff --git a/src/functions/activities/Quiz.ts b/src/functions/activities/Quiz.ts
index e0a15a7..4aa68d0 100644
--- a/src/functions/activities/Quiz.ts
+++ b/src/functions/activities/Quiz.ts
@@ -30,7 +30,7 @@ export class Quiz extends Workers {
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
- const answerAttribute = await answerSelector?.evaluate((el: any) => el.getAttribute('iscorrectoption'))
+ const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption'))
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
answers.push(`#rqAnswerOption${i}`)
@@ -60,7 +60,7 @@ export class Quiz extends Workers {
for (let i = 0; i < quizData.numberOfOptions; i++) {
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
- const dataOption = await answerSelector?.evaluate((el: any) => el.getAttribute('data-option'))
+ const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option'))
if (dataOption === correctOption) {
// Click the answer on page
@@ -84,6 +84,7 @@ export class Quiz extends Workers {
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')
} catch (error) {
+ await this.bot.browser.utils.captureDiagnostics(page, 'quiz_error')
await page.close()
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred:' + error, 'error')
}
diff --git a/src/functions/activities/Search.ts b/src/functions/activities/Search.ts
index c96c2e5..e65107d 100644
--- a/src/functions/activities/Search.ts
+++ b/src/functions/activities/Search.ts
@@ -33,12 +33,32 @@ export class Search extends Workers {
return
}
- // Generate search queries
- let googleSearchQueries = await this.getGoogleTrends(this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US')
- googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
+ // Generate search queries (primary: Google Trends)
+ const geo = this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US'
+ let googleSearchQueries = await this.getGoogleTrends(geo)
- // Deduplicate the search terms
- googleSearchQueries = [...new Set(googleSearchQueries)]
+ // Fallback: if trends failed or insufficient, sample from local queries file
+ if (!googleSearchQueries.length || googleSearchQueries.length < 10) {
+ this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Primary trends source insufficient, falling back to local queries.json', 'warn')
+ try {
+ const local = await import('../queries.json')
+ // Flatten & sample
+ const sampleSize = Math.max(5, Math.min(this.bot.config.searchSettings.localFallbackCount || 25, local.default.length))
+ const sampled = this.bot.utils.shuffleArray(local.default).slice(0, sampleSize)
+ googleSearchQueries = sampled.map((x: { title: string; queries: string[] }) => ({ topic: x.queries[0] || x.title, related: x.queries.slice(1) }))
+ } catch (e) {
+ this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed loading local queries fallback: ' + (e instanceof Error ? e.message : e), 'error')
+ }
+ }
+
+ googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
+ // Deduplicate topics
+ const seen = new Set()
+ googleSearchQueries = googleSearchQueries.filter(q => {
+ if (seen.has(q.topic.toLowerCase())) return false
+ seen.add(q.topic.toLowerCase())
+ return true
+ })
// Go to bing
await page.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
@@ -47,7 +67,7 @@ export class Search extends Workers {
await this.bot.browser.utils.tryDismissAllMessages(page)
- let maxLoop = 0 // If the loop hits 10 this when not gaining any points, we're assuming it's stuck. If it doesn't continue after 5 more searches with alternative queries, abort search
+ let stagnation = 0 // consecutive searches without point progress
const queries: string[] = []
// Mobile search doesn't seem to like related queries?
@@ -63,28 +83,26 @@ export class Search extends Workers {
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
- if (newMissingPoints == missingPoints) {
- maxLoop++ // Add to max loop
- } else { // There has been a change in points
- maxLoop = 0 // Reset the loop
+ if (newMissingPoints === missingPoints) {
+ stagnation++
+ } else {
+ stagnation = 0
}
missingPoints = newMissingPoints
- if (missingPoints === 0) {
- break
- }
+ if (missingPoints === 0) break
// Only for mobile searches
- if (maxLoop > 5 && this.bot.isMobile) {
+ if (stagnation > 5 && this.bot.isMobile) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 5 iterations, likely bad User-Agent', 'warn')
break
}
// If we didn't gain points for 10 iterations, assume it's stuck
- if (maxLoop > 10) {
+ if (stagnation > 10) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
- maxLoop = 0 // Reset to 0 so we can retry with related searches below
+ stagnation = 0 // allow fallback loop below
break
}
}
@@ -99,8 +117,11 @@ export class Search extends Workers {
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
let i = 0
- while (missingPoints > 0) {
+ let fallbackRounds = 0
+ const extraRetries = this.bot.config.searchSettings.extraFallbackRetries || 1
+ while (missingPoints > 0 && fallbackRounds <= extraRetries) {
const query = googleSearchQueries[i++] as GoogleSearch
+ if (!query) break
// Get related search terms to the Google search queries
const relatedTerms = await this.getRelatedTerms(query?.topic)
@@ -113,10 +134,10 @@ export class Search extends Workers {
const newMissingPoints = this.calculatePoints(searchCounters)
// If the new point amount is the same as before
- if (newMissingPoints == missingPoints) {
- maxLoop++ // Add to max loop
- } else { // There has been a change in points
- maxLoop = 0 // Reset the loop
+ if (newMissingPoints === missingPoints) {
+ stagnation++
+ } else {
+ stagnation = 0
}
missingPoints = newMissingPoints
@@ -127,11 +148,12 @@ export class Search extends Workers {
}
// Try 5 more times, then we tried a total of 15 times, fair to say it's stuck
- if (maxLoop > 5) {
+ if (stagnation > 5) {
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
return
}
}
+ fallbackRounds++
}
}
}
@@ -156,20 +178,38 @@ export class Search extends Workers {
await this.bot.utils.wait(500)
const searchBar = '#sb_form_q'
- await searchPage.waitForSelector(searchBar, { state: 'visible', timeout: 10000 })
- await searchPage.click(searchBar) // Focus on the textarea
- await this.bot.utils.wait(500)
- await searchPage.keyboard.down(platformControlKey)
- await searchPage.keyboard.press('A')
- await searchPage.keyboard.press('Backspace')
- await searchPage.keyboard.up(platformControlKey)
- await searchPage.keyboard.type(query)
- await searchPage.keyboard.press('Enter')
+ // Prefer attached over visible to avoid strict visibility waits when overlays exist
+ const box = searchPage.locator(searchBar)
+ await box.waitFor({ state: 'attached', timeout: 15000 })
+
+ // Try dismissing overlays before interacting
+ await this.bot.browser.utils.tryDismissAllMessages(searchPage)
+ await this.bot.utils.wait(200)
+
+ let navigatedDirectly = false
+ try {
+ // Try focusing and filling instead of clicking (more reliable on mobile)
+ await box.focus({ timeout: 2000 }).catch(() => { /* ignore focus errors */ })
+ await box.fill('')
+ await this.bot.utils.wait(200)
+ await searchPage.keyboard.down(platformControlKey)
+ await searchPage.keyboard.press('A')
+ await searchPage.keyboard.press('Backspace')
+ await searchPage.keyboard.up(platformControlKey)
+ await box.type(query, { delay: 20 })
+ await searchPage.keyboard.press('Enter')
+ } catch (typeErr) {
+ // As a robust fallback, navigate directly to the search results URL
+ const q = encodeURIComponent(query)
+ const url = `https://www.bing.com/search?q=${q}`
+ await searchPage.goto(url)
+ navigatedDirectly = true
+ }
await this.bot.utils.wait(3000)
- // Bing.com in Chrome opens a new tab when searching
- const resultPage = await this.bot.browser.utils.getLatestTab(searchPage)
+ // Bing.com in Chrome opens a new tab when searching via Enter; if we navigated directly, stay on current tab
+ const resultPage = navigatedDirectly ? searchPage : await this.bot.browser.utils.getLatestTab(searchPage)
this.searchPageURL = new URL(resultPage.url()).href // Set the results page
await this.bot.browser.utils.reloadBadPage(resultPage)
@@ -185,7 +225,10 @@ export class Search extends Workers {
}
// Delay between searches
- await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min), this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max))))
+ const minDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min)
+ const maxDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max)
+ const adaptivePad = Math.min(4000, Math.max(0, Math.floor(Math.random() * 800)))
+ await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(minDelay, maxDelay)) + adaptivePad)
return await this.bot.browser.func.getSearchPoints()
diff --git a/src/functions/activities/SearchOnBing.ts b/src/functions/activities/SearchOnBing.ts
index 1a8e91c..561d49b 100644
--- a/src/functions/activities/SearchOnBing.ts
+++ b/src/functions/activities/SearchOnBing.ts
@@ -20,11 +20,20 @@ export class SearchOnBing extends Workers {
const query = await this.getSearchQuery(activity.title)
const searchBar = '#sb_form_q'
- await page.waitForSelector(searchBar, { state: 'visible', timeout: 10000 })
- await this.safeClick(page, searchBar)
- await this.bot.utils.wait(500)
- await page.keyboard.type(query)
- await page.keyboard.press('Enter')
+ const box = page.locator(searchBar)
+ await box.waitFor({ state: 'attached', timeout: 15000 })
+ await this.bot.browser.utils.tryDismissAllMessages(page)
+ await this.bot.utils.wait(200)
+ try {
+ await box.focus({ timeout: 2000 }).catch(() => { /* ignore */ })
+ await box.fill('')
+ await this.bot.utils.wait(200)
+ await page.keyboard.type(query, { delay: 20 })
+ await page.keyboard.press('Enter')
+ } catch {
+ const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
+ await page.goto(url)
+ }
await this.bot.utils.wait(3000)
await page.close()
@@ -36,22 +45,6 @@ export class SearchOnBing extends Workers {
}
}
- private async safeClick(page: Page, selector: string) {
- try {
- await page.click(selector, { timeout: 5000 })
- } catch (e: any) {
- const msg = (e?.message || '')
- if (/Timeout.*click/i.test(msg) || /intercepts pointer events/i.test(msg)) {
- // Try to dismiss overlays then retry once
- await this.bot.browser.utils.tryDismissAllMessages(page)
- await this.bot.utils.wait(500)
- await page.click(selector, { timeout: 5000 })
- } else {
- throw e
- }
- }
- }
-
private async getSearchQuery(title: string): Promise {
interface Queries {
title: string;
diff --git a/src/index.ts b/src/index.ts
index 1585591..ca2d99f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,5 @@
import cluster from 'cluster'
+import type { Worker } from 'cluster'
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
import type { Page } from 'playwright'
@@ -18,6 +19,9 @@ import { Account } from './interface/Account'
import Axios from './util/Axios'
import fs from 'fs'
import path from 'path'
+import { spawn } from 'child_process'
+import Humanizer from './util/Humanizer'
+import { detectBanReason } from './util/BanDetector'
// Main bot class
@@ -30,8 +34,14 @@ export class MicrosoftRewardsBot {
func: BrowserFunc,
utils: BrowserUtil
}
+ public humanizer: Humanizer
public isMobile: boolean
public homePage!: Page
+ public currentAccountEmail?: string
+ public currentAccountRecoveryEmail?: string
+ public compromisedModeActive: boolean = false
+ public compromisedReason?: string
+ public compromisedEmail?: string
private pointsCanCollect: number = 0
private pointsInitial: number = 0
@@ -43,9 +53,18 @@ export class MicrosoftRewardsBot {
private workers: Workers
private login = new Login(this)
private accessToken: string = ''
+ // Buy mode (manual spending) tracking
+ private buyMode: { enabled: boolean; email?: string } = { enabled: false }
// Summary collection (per process)
private accountSummaries: AccountSummary[] = []
+ private runId: string = Math.random().toString(36).slice(2)
+ private diagCount: number = 0
+ private bannedTriggered: { email: string; reason: string } | null = null
+ private globalStandby: { active: boolean; reason?: string } = { active: false }
+ // Scheduler heartbeat integration
+ private heartbeatFile?: string
+ private heartbeatTimer?: NodeJS.Timeout
//@ts-expect-error Will be initialized later
public axios: Axios
@@ -56,14 +75,31 @@ export class MicrosoftRewardsBot {
this.accounts = []
this.utils = new Util()
- this.workers = new Workers(this)
+ this.config = loadConfig()
this.browser = {
func: new BrowserFunc(this),
utils: new BrowserUtil(this)
}
- this.config = loadConfig()
+ this.workers = new Workers(this)
+ this.humanizer = new Humanizer(this.utils, this.config.humanization)
this.activeWorkers = this.config.clusters
this.mobileRetryAttempts = 0
+ // Base buy mode from config
+ const cfgAny = this.config as unknown as { buyMode?: { enabled?: boolean } }
+ if (cfgAny.buyMode?.enabled === true) {
+ this.buyMode.enabled = true
+ }
+
+ // CLI: detect buy mode flag and target email (overrides config)
+ const idx = process.argv.indexOf('-buy')
+ if (idx >= 0) {
+ const target = process.argv[idx + 1]
+ if (target && /@/.test(target)) {
+ this.buyMode = { enabled: true, email: target }
+ } else {
+ this.buyMode = { enabled: true }
+ }
+ }
}
async initialize() {
@@ -74,6 +110,29 @@ export class MicrosoftRewardsBot {
this.printBanner()
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
+ // If scheduler provided a heartbeat file, update it periodically to signal liveness
+ const hbFile = process.env.SCHEDULER_HEARTBEAT_FILE
+ if (hbFile) {
+ try {
+ const dir = path.dirname(hbFile)
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
+ fs.writeFileSync(hbFile, String(Date.now()))
+ this.heartbeatFile = hbFile
+ this.heartbeatTimer = setInterval(() => {
+ try { fs.writeFileSync(hbFile, String(Date.now())) } catch { /* ignore */ }
+ }, 60_000)
+ } catch { /* ignore */ }
+ }
+
+ // If buy mode is enabled, run single-account interactive session without automation
+ if (this.buyMode.enabled) {
+ const targetInfo = this.buyMode.email ? ` for ${this.buyMode.email}` : ''
+ log('main', 'BUY-MODE', `Buy mode ENABLED${targetInfo}. We'll open 2 tabs: (1) a monitor tab that auto-refreshes to track points, (2) your browsing tab to redeem/purchase freely.`, 'log', 'green')
+ log('main', 'BUY-MODE', 'The monitor tab may refresh every ~10s. Use the other tab for your actions; monitoring is passive and non-intrusive.', 'log', 'yellow')
+ await this.runBuyMode()
+ return
+ }
+
// Only cluster when there's more than 1 cluster demanded
if (this.config.clusters > 1) {
if (cluster.isPrimary) {
@@ -86,9 +145,166 @@ export class MicrosoftRewardsBot {
}
}
+ /** Manual spending session: login, then leave control to user while we passively monitor points. */
+ private async runBuyMode() {
+ try {
+ await this.initialize()
+ const email = this.buyMode.email || (this.accounts[0]?.email)
+ const account = this.accounts.find(a => a.email === email) || this.accounts[0]
+ if (!account) throw new Error('No account available for buy mode')
+
+ this.isMobile = false
+ this.axios = new Axios(account.proxy)
+ const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
+ // Open the monitor tab FIRST so auto-refresh happens out of the way
+ let monitor = await browser.newPage()
+ await this.login.login(monitor, account.email, account.password, account.totp)
+ await this.browser.func.goHome(monitor)
+ this.log(false, 'BUY-MODE', 'Opened MONITOR tab (auto-refreshes to track points).', 'log', 'yellow')
+
+ // Then open the user free-browsing tab SECOND so users donโt see the refreshes
+ const page = await browser.newPage()
+ await this.browser.func.goHome(page)
+ this.log(false, 'BUY-MODE', 'Opened USER tab (use this one to redeem/purchase freely).', 'log', 'green')
+
+ // Helper to recreate monitor tab if the user closes it
+ const recreateMonitor = async () => {
+ try { if (!monitor.isClosed()) await monitor.close() } catch { /* ignore */ }
+ monitor = await browser.newPage()
+ await this.browser.func.goHome(monitor)
+ }
+
+ // Helper to send an immediate spend notice via webhooks/NTFY
+ const sendSpendNotice = async (delta: number, nowPts: number, cumulativeSpent: number) => {
+ try {
+ const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
+ const title = '๐ณ Spend detected (Buy Mode)'
+ const desc = [
+ `Account: ${account.email}`,
+ `Spent: -${delta} points`,
+ `Current: ${nowPts} points`,
+ `Session spent: ${cumulativeSpent} points`
+ ].join('\n')
+ await ConclusionWebhook(this.config, '', {
+ context: 'spend',
+ embeds: [
+ {
+ title,
+ description: desc,
+ // Use warn color so NTFY is sent as warn
+ color: 0xFFAA00
+ }
+ ]
+ })
+ } catch (e) {
+ this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn')
+ }
+ }
+ let initial = 0
+ try {
+ const data = await this.browser.func.getDashboardData(monitor)
+ initial = data.userStatus.availablePoints || 0
+ } catch {/* ignore */}
+
+ this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Buy mode is active: monitor tab auto-refreshes; user tab is free for your actions. We'll observe points passively.`)
+
+ // Passive watcher: poll points periodically without clicking.
+ const start = Date.now()
+ let last = initial
+ let spent = 0
+
+ const cfgAny = this.config as unknown as Record
+ const buyModeConfig = cfgAny['buyMode'] as Record | undefined
+ const maxMinutesRaw = buyModeConfig?.['maxMinutes'] ?? 45
+ const maxMinutes = Math.max(10, Number(maxMinutesRaw))
+ const endAt = start + maxMinutes * 60 * 1000
+
+ while (Date.now() < endAt) {
+ await this.utils.wait(10000)
+
+ // If monitor tab was closed by user, recreate it quietly
+ try {
+ if (monitor.isClosed()) {
+ this.log(false, 'BUY-MODE', 'Monitor tab was closed; reopening in background...', 'warn')
+ await recreateMonitor()
+ }
+ } catch { /* ignore */ }
+
+ try {
+ const data = await this.browser.func.getDashboardData(monitor)
+ const nowPts = data.userStatus.availablePoints || 0
+ if (nowPts < last) {
+ // Points decreased -> likely spent
+ const delta = last - nowPts
+ spent += delta
+ last = nowPts
+ this.log(false, 'BUY-MODE', `Detected spend: -${delta} points (current: ${nowPts})`)
+ // Immediate spend notice
+ await sendSpendNotice(delta, nowPts, spent)
+ } else if (nowPts > last) {
+ last = nowPts
+ }
+ } catch (err) {
+ // If we lost the page context, recreate the monitor tab and continue
+ const msg = err instanceof Error ? err.message : String(err)
+ if (/Target closed|page has been closed|browser has been closed/i.test(msg)) {
+ this.log(false, 'BUY-MODE', 'Monitor page closed or lost; recreating...', 'warn')
+ try { await recreateMonitor() } catch { /* ignore */ }
+ }
+ // Swallow other errors to avoid disrupting the user
+ }
+ }
+
+ // Save cookies and close monitor; keep main page open for user until they close it themselves
+ try { await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile) } catch { /* ignore */ }
+ try { if (!monitor.isClosed()) await monitor.close() } catch {/* ignore */}
+
+ // Send a final minimal conclusion webhook for this manual session
+ const summary: AccountSummary = {
+ email: account.email,
+ durationMs: Date.now() - start,
+ desktopCollected: 0,
+ mobileCollected: 0,
+ totalCollected: -spent, // negative indicates spend
+ initialTotal: initial,
+ endTotal: last,
+ errors: [],
+ banned: { status: false, reason: '' }
+ }
+ await this.sendConclusion([summary])
+
+ this.log(false, 'BUY-MODE', 'Buy mode session finished (monitoring period ended). You can close the browser when done.')
+ } catch (e) {
+ this.log(false, 'BUY-MODE', `Error in buy mode: ${e instanceof Error ? e.message : e}`, 'error')
+ }
+ }
+
private printBanner() {
// Only print once (primary process or single cluster execution)
if (this.config.clusters > 1 && !cluster.isPrimary) return
+
+ const banner = `
+ โโโโ โโโโโโโโโโโโ โโโโโโโ โโโโโโโโโโโ โโโ โโโโโโ โโโโโโโ โโโโโโโ โโโโโโโโ
+ โโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโ โโ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ
+ โโโ โโโ โโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโโโโโโโโโโโโโโ
+ โโโ โโโโโโโโโโโ โโโ โโโโโโโโโโโ โโโโโโโโ โโโ โโโโโโ โโโโโโโโโโ โโโโโโโโ
+
+ TypeScript โข Playwright โข Automated Point Collection
+`
+
+ const buyModeBanner = `
+ โโโโ โโโโโโโโโโโโ โโโโโโโ โโโ โโโโโโ โโโ
+ โโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโ โโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโโโ
+ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโ โโโโโ
+ โโโ โโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โโโ
+ โโโ โโโโโโโโโโโ โโโโโโโ โโโโโโโ โโโ
+
+ By @Light โข Manual Purchase Mode โข Passive Monitoring
+`
+
try {
const pkgPath = path.join(__dirname, '../', 'package.json')
let version = 'unknown'
@@ -97,22 +313,69 @@ export class MicrosoftRewardsBot {
const pkg = JSON.parse(raw)
version = pkg.version || version
}
- const banner = [
- ' __ __ _____ _____ _ ',
- ' | \/ |/ ____| | __ \\ | | ',
- ' | \ / | (___ ______| |__) |_____ ____ _ _ __ __| |___ ',
- ' | |\/| |\\___ \\______| _ // _ \\ \\ /\\ / / _` | \'__/ _` / __|',
- ' | | | |____) | | | \\ \\ __/ \\ V V / (_| | | | (_| \\__ \\',
- ' |_| |_|_____/ |_| \\_\\___| \\_/\\_/ \\__,_|_| \\__,_|___/',
- '',
- ` Version: v${version}`,
- ''
- ].join('\n')
- console.log(banner)
- } catch { /* ignore banner errors */ }
- }
+
+ // Show appropriate banner based on mode
+ const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
+ console.log(displayBanner)
+ console.log('='.repeat(80))
+
+ if (this.buyMode.enabled) {
+ console.log(` Version: ${version} | Process: ${process.pid} | Buy Mode: Active`)
+ console.log(` Target: ${this.buyMode.email || 'First account'} | Documentation: buy-mode.md`)
+ } else {
+ console.log(` Version: ${version} | Process: ${process.pid} | Clusters: ${this.config.clusters}`)
+ // Replace visibility/parallel with concise enabled feature status
+ const upd = this.config.update || {}
+ const updTargets: string[] = []
+ if (upd.git !== false) updTargets.push('Git')
+ if (upd.docker) updTargets.push('Docker')
+ if (updTargets.length > 0) {
+ console.log(` Update: ${updTargets.join(', ')}`)
+ }
- // Return summaries (used when clusters==1)
+ const sched = this.config.schedule || {}
+ const schedEnabled = !!sched.enabled
+ if (!schedEnabled) {
+ console.log(' Schedule: OFF')
+ } else {
+ // Determine active format + time string to display
+ const tz = sched.timeZone || 'UTC'
+ let formatName = ''
+ let timeShown = ''
+ const srec: Record = sched as unknown as Record
+ const useAmPmVal = typeof srec['useAmPm'] === 'boolean' ? (srec['useAmPm'] as boolean) : undefined
+ const time12Val = typeof srec['time12'] === 'string' ? String(srec['time12']) : undefined
+ const time24Val = typeof srec['time24'] === 'string' ? String(srec['time24']) : undefined
+
+ if (useAmPmVal === true) {
+ formatName = 'AM/PM'
+ timeShown = time12Val || sched.time || '9:00 AM'
+ } else if (useAmPmVal === false) {
+ formatName = '24h'
+ timeShown = time24Val || sched.time || '09:00'
+ } else {
+ // Back-compat: infer from provided fields if possible
+ if (time24Val && time24Val.trim()) { formatName = '24h'; timeShown = time24Val }
+ else if (time12Val && time12Val.trim()) { formatName = 'AM/PM'; timeShown = time12Val }
+ else { formatName = 'legacy'; timeShown = sched.time || '09:00' }
+ }
+ console.log(` Schedule: ON โ ${formatName} โข ${timeShown} โข TZ=${tz}`)
+ }
+ }
+ console.log('='.repeat(80) + '\n')
+ } catch {
+ const displayBanner = this.buyMode.enabled ? buyModeBanner : banner
+ console.log(displayBanner)
+ console.log('='.repeat(50))
+ if (this.buyMode.enabled) {
+ console.log(' Microsoft Rewards Buy Mode Started')
+ console.log(' See buy-mode.md for details')
+ } else {
+ console.log(' Microsoft Rewards Script Started')
+ }
+ console.log('='.repeat(50) + '\n')
+ }
+ } // Return summaries (used when clusters==1)
public getSummaries() {
return this.accountSummaries
}
@@ -120,32 +383,61 @@ export class MicrosoftRewardsBot {
private runMaster() {
log('main', 'MAIN-PRIMARY', 'Primary process started')
- const accountChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
+ const totalAccounts = this.accounts.length
+ // If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
+ const workerCount = Math.min(this.config.clusters, totalAccounts || 1)
+ const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
+ // Reset activeWorkers to actual spawn count (constructor used raw clusters)
+ this.activeWorkers = workerCount
- for (let i = 0; i < accountChunks.length; i++) {
+ for (let i = 0; i < workerCount; i++) {
const worker = cluster.fork()
- const chunk = accountChunks[i]
- ;(worker as any).send?.({ chunk })
- // Collect summaries from workers
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'summary' && Array.isArray(msg.data)) {
- this.accountSummaries.push(...msg.data)
+ const chunk = accountChunks[i] || []
+ ;(worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk })
+ worker.on('message', (msg: unknown) => {
+ const m = msg as { type?: string; data?: AccountSummary[] }
+ if (m && m.type === 'summary' && Array.isArray(m.data)) {
+ this.accountSummaries.push(...m.data)
}
})
}
- cluster.on('exit', (worker: any, code: number) => {
+ cluster.on('exit', (worker: Worker, code: number) => {
this.activeWorkers -= 1
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
+ // Optional: restart crashed worker (basic heuristic) if crashRecovery allows
+ try {
+ const cr = this.config.crashRecovery
+ if (cr?.restartFailedWorker && code !== 0) {
+ const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0
+ if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
+ (worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1
+ log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow')
+ const newW = cluster.fork()
+ // NOTE: account chunk re-assignment simplistic: unused; real mapping improvement todo
+ newW.on('message', (msg: unknown) => {
+ const m = msg as { type?: string; data?: AccountSummary[] }
+ if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data)
+ })
+ }
+ }
+ } catch { /* ignore */ }
+
// Check if all workers have exited
if (this.activeWorkers === 0) {
- // All workers done -> send conclusion (if enabled) then exit
- this.sendConclusion(this.accountSummaries).finally(() => {
+ // All workers done -> send conclusion (if enabled), run optional auto-update, then exit
+ (async () => {
+ try {
+ await this.sendConclusion(this.accountSummaries)
+ } catch {/* ignore */}
+ try {
+ await this.runAutoUpdate()
+ } catch {/* ignore */}
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
process.exit(0)
- })
+ })()
}
})
}
@@ -153,13 +445,40 @@ export class MicrosoftRewardsBot {
private runWorker() {
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
// Receive the chunk of accounts from the master
- ;(process as any).on('message', async ({ chunk }: { chunk: Account[] }) => {
+ ;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => {
await this.runTasks(chunk)
})
}
private async runTasks(accounts: Account[]) {
for (const account of accounts) {
+ // If a global standby is active due to security/banned, stop processing further accounts
+ if (this.globalStandby.active) {
+ log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow')
+ break
+ }
+ // Optional global stop after first ban
+ if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) {
+ log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn')
+ break
+ }
+ // Reset compromised state per account
+ this.compromisedModeActive = false
+ this.compromisedReason = undefined
+ this.compromisedEmail = undefined
+ // If humanization allowed windows are configured, wait until within a window
+ try {
+ const windows: string[] | undefined = this.config?.humanization?.allowedWindows
+ if (Array.isArray(windows) && windows.length > 0) {
+ const waitMs = this.computeWaitForAllowedWindow(windows)
+ if (waitMs > 0) {
+ log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn')
+ await new Promise(r => setTimeout(r, waitMs))
+ }
+ }
+ } catch {/* ignore */}
+ this.currentAccountEmail = account.email
+ this.currentAccountRecoveryEmail = account.recoveryEmail
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
const accountStart = Date.now()
@@ -168,10 +487,11 @@ export class MicrosoftRewardsBot {
let desktopCollected = 0
let mobileCollected = 0
const errors: string[] = []
+ const banned = { status: false, reason: '' }
this.axios = new Axios(account.proxy)
const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1'
- const formatFullErr = (label: string, e: any) => {
+ const formatFullErr = (label: string, e: unknown) => {
const base = shortErr(e)
if (verbose && e instanceof Error) {
return `${label}:${base} :: ${e.stack?.split('\n').slice(0,4).join(' | ')}`
@@ -184,11 +504,23 @@ export class MicrosoftRewardsBot {
mobileInstance.axios = this.axios
// Run both and capture results with detailed logging
const desktopPromise = this.Desktop(account).catch(e => {
- log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${e instanceof Error ? e.message : e}`,'error')
+ const msg = e instanceof Error ? e.message : String(e)
+ log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
+ const bd = detectBanReason(e)
+ if (bd.status) {
+ banned.status = true; banned.reason = bd.reason.substring(0,200)
+ void this.handleImmediateBanAlert(account.email, banned.reason)
+ }
errors.push(formatFullErr('desktop', e)); return null
})
const mobilePromise = mobileInstance.Mobile(account).catch(e => {
- log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${e instanceof Error ? e.message : e}`,'error')
+ const msg = e instanceof Error ? e.message : String(e)
+ log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
+ const bd = detectBanReason(e)
+ if (bd.status) {
+ banned.status = true; banned.reason = bd.reason.substring(0,200)
+ void this.handleImmediateBanAlert(account.email, banned.reason)
+ }
errors.push(formatFullErr('mobile', e)); return null
})
const [desktopResult, mobileResult] = await Promise.all([desktopPromise, mobilePromise])
@@ -203,7 +535,13 @@ export class MicrosoftRewardsBot {
} else {
this.isMobile = false
const desktopResult = await this.Desktop(account).catch(e => {
- log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${e instanceof Error ? e.message : e}`,'error')
+ const msg = e instanceof Error ? e.message : String(e)
+ log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
+ const bd = detectBanReason(e)
+ if (bd.status) {
+ banned.status = true; banned.reason = bd.reason.substring(0,200)
+ void this.handleImmediateBanAlert(account.email, banned.reason)
+ }
errors.push(formatFullErr('desktop', e)); return null
})
if (desktopResult) {
@@ -211,21 +549,45 @@ export class MicrosoftRewardsBot {
desktopCollected = desktopResult.collectedPoints
}
- this.isMobile = true
- const mobileResult = await this.Mobile(account).catch(e => {
- log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${e instanceof Error ? e.message : e}`,'error')
- errors.push(formatFullErr('mobile', e)); return null
- })
- if (mobileResult) {
- mobileInitial = mobileResult.initialPoints
- mobileCollected = mobileResult.collectedPoints
+ // If banned or compromised detected, skip mobile to save time
+ if (!banned.status && !this.compromisedModeActive) {
+ this.isMobile = true
+ const mobileResult = await this.Mobile(account).catch(e => {
+ const msg = e instanceof Error ? e.message : String(e)
+ log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
+ const bd = detectBanReason(e)
+ if (bd.status) {
+ banned.status = true; banned.reason = bd.reason.substring(0,200)
+ void this.handleImmediateBanAlert(account.email, banned.reason)
+ }
+ errors.push(formatFullErr('mobile', e)); return null
+ })
+ if (mobileResult) {
+ mobileInitial = mobileResult.initialPoints
+ mobileCollected = mobileResult.collectedPoints
+ }
+ } else {
+ const why = banned.status ? 'banned status' : 'compromised status'
+ log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
}
}
const accountEnd = Date.now()
const durationMs = accountEnd - accountStart
const totalCollected = desktopCollected + mobileCollected
- const initialTotal = (desktopInitial || 0) + (mobileInitial || 0)
+ // Correct initial points (previous version double counted desktop+mobile baselines)
+ // Strategy: pick the lowest non-zero baseline (desktopInitial or mobileInitial) as true start.
+ // Sequential flow: desktopInitial < mobileInitial after gain -> min = original baseline.
+ // Parallel flow: both baselines equal -> min is fine.
+ const baselines: number[] = []
+ if (desktopInitial) baselines.push(desktopInitial)
+ if (mobileInitial) baselines.push(mobileInitial)
+ let initialTotal = 0
+ if (baselines.length === 1) initialTotal = baselines[0]!
+ else if (baselines.length === 2) initialTotal = Math.min(baselines[0]!, baselines[1]!)
+ // Fallback if both missing
+ if (initialTotal === 0 && (desktopInitial || mobileInitial)) initialTotal = desktopInitial || mobileInitial || 0
+ const endTotal = initialTotal + totalCollected
this.accountSummaries.push({
email: account.email,
durationMs,
@@ -233,20 +595,37 @@ export class MicrosoftRewardsBot {
mobileCollected,
totalCollected,
initialTotal,
- endTotal: initialTotal + totalCollected,
- errors
+ endTotal,
+ errors,
+ banned
})
- log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
+ if (banned.status) {
+ this.bannedTriggered = { email: account.email, reason: banned.reason }
+ // Enter global standby: do not proceed to next accounts
+ this.globalStandby = { active: true, reason: `banned:${banned.reason}` }
+ await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`)
+ }
+
+ await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
}
- log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
+ await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
// Extra diagnostic summary when verbose
if (process.env.DEBUG_REWARDS_VERBOSE === '1') {
for (const summary of this.accountSummaries) {
log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`)
}
}
+ // If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
+ if (this.compromisedModeActive || this.globalStandby.active) {
+ log('main','SECURITY','Compromised or banned detected. Global standby engaged: we will NOT proceed to other accounts until resolved. Keeping process alive. Press CTRL+C to exit when done. Security check by @Light','warn','yellow')
+ // Periodic heartbeat
+ setInterval(() => {
+ log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow')
+ }, 5 * 60 * 1000)
+ return
+ }
// If in worker mode (clusters>1) send summaries to primary
if (this.config.clusters > 1 && !cluster.isPrimary) {
if (process.send) {
@@ -255,10 +634,73 @@ export class MicrosoftRewardsBot {
} else {
// Single process mode -> build and send conclusion directly
await this.sendConclusion(this.accountSummaries)
+ // Cleanup heartbeat timer/file at end of run
+ if (this.heartbeatTimer) { try { clearInterval(this.heartbeatTimer) } catch { /* ignore */ } }
+ if (this.heartbeatFile) { try { if (fs.existsSync(this.heartbeatFile)) fs.unlinkSync(this.heartbeatFile) } catch { /* ignore */ } }
+ // After conclusion, run optional auto-update
+ await this.runAutoUpdate().catch(() => {/* ignore update errors */})
}
process.exit()
}
+ /** Send immediate ban alert if configured. */
+ private async handleImmediateBanAlert(email: string, reason: string): Promise {
+ try {
+ const h = this.config?.humanization
+ if (!h || h.immediateBanAlert === false) return
+ const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
+ const title = '๐ซ Ban detected'
+ const desc = [`Account: ${email}`, `Reason: ${reason || 'detected by heuristics'}`].join('\n')
+ await ConclusionWebhook(this.config, `${title}\n${desc}`, {
+ embeds: [
+ {
+ title,
+ description: desc,
+ color: 0xFF0000
+ }
+ ]
+ })
+ } catch (e) {
+ log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn')
+ }
+ }
+
+ /** Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). Returns 0 if already inside. */
+ private computeWaitForAllowedWindow(windows: string[]): number {
+ const now = new Date()
+ const minsNow = now.getHours() * 60 + now.getMinutes()
+ let nextStartMins: number | null = null
+ for (const w of windows) {
+ const [start, end] = w.split('-')
+ if (!start || !end) continue
+ const pStart = start.split(':').map(v=>parseInt(v,10))
+ const pEnd = end.split(':').map(v=>parseInt(v,10))
+ if (pStart.length !== 2 || pEnd.length !== 2) continue
+ const sh = pStart[0]!, sm = pStart[1]!
+ const eh = pEnd[0]!, em = pEnd[1]!
+ if ([sh,sm,eh,em].some(n=>Number.isNaN(n))) continue
+ const s = sh*60 + sm
+ const e = eh*60 + em
+ if (s <= e) {
+ // same-day window
+ if (minsNow >= s && minsNow <= e) return 0
+ if (minsNow < s) nextStartMins = Math.min(nextStartMins ?? s, s)
+ } else {
+ // wraps past midnight (e.g., 22:00-02:00)
+ if (minsNow >= s || minsNow <= e) return 0
+ // next start today is s
+ nextStartMins = Math.min(nextStartMins ?? s, s)
+ }
+ }
+ const msPerMin = 60*1000
+ if (nextStartMins != null) {
+ const targetTodayMs = (nextStartMins - minsNow) * msPerMin
+ return targetTodayMs > 0 ? targetTodayMs : (24*60 + nextStartMins - minsNow) * msPerMin
+ }
+ // No valid windows parsed -> do not block
+ return 0
+ }
+
// Desktop
async Desktop(account: Account) {
log(false,'FLOW','Desktop() invoked')
@@ -267,8 +709,30 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'MAIN', 'Starting browser')
- // Login into MS Rewards, then go to rewards homepage
- await this.login.login(this.homePage, account.email, account.password)
+ // Login into MS Rewards, then optionally stop if compromised
+ await this.login.login(this.homePage, account.email, account.password, account.totp)
+
+ 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')
+ try {
+ const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
+ await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Logged in successfully; leaving browser open. Security check by @Light`, {
+ context: 'compromised',
+ embeds: [
+ {
+ title: '๐ Security alert (post-login)',
+ description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving browser open; skipping tasks`,
+ color: 0xFFAA00
+ }
+ ]
+ })
+ } catch {/* ignore */}
+ // Save session for convenience, but do not close the browser
+ try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
+ return { initialPoints: 0, collectedPoints: 0 }
+ }
await this.browser.func.goHome(this.homePage)
@@ -288,6 +752,12 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today`)
+ if (this.pointsCanCollect === 0) {
+ // Extra diagnostic breakdown so users know WHY it's zero
+ log(this.isMobile, 'MAIN-POINTS', `Breakdown (desktop): dailySet=${browserEnarablePoints.dailySetPoints} search=${browserEnarablePoints.desktopSearchPoints} promotions=${browserEnarablePoints.morePromotionsPoints}`)
+ log(this.isMobile, 'MAIN-POINTS', 'All desktop earnable buckets are zero. This usually means: tasks already completed today OR the daily reset has not happened yet for your time zone. If you still want to force run activities set execution.runOnZeroPoints=true in config.', 'log', 'yellow')
+ }
+
// If runOnZeroPoints is false and 0 points to earn, don't continue
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
@@ -344,8 +814,27 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'MAIN', 'Starting browser')
- // Login into MS Rewards, then go to rewards homepage
- await this.login.login(this.homePage, account.email, account.password)
+ // Login into MS Rewards, then respect compromised mode
+ 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')
+ try {
+ const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
+ await ConclusionWebhook(this.config, `Security issue on ${account.email} (${reason}). Mobile flow halted; leaving browser open. Security check by @Light`, {
+ context: 'compromised',
+ embeds: [
+ {
+ title: '๐ Security alert (mobile)',
+ description: `Account: ${account.email}\nReason: ${reason}\nAction: Leaving mobile browser open; skipping tasks`,
+ color: 0xFFAA00
+ }
+ ]
+ })
+ } catch {/* ignore */}
+ try { await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile) } catch { /* ignore */ }
+ return { initialPoints: 0, collectedPoints: 0 }
+ }
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
await this.browser.func.goHome(this.homePage)
@@ -360,6 +849,11 @@ export class MicrosoftRewardsBot {
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today (Browser: ${browserEnarablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
+ if (this.pointsCanCollect === 0) {
+ log(this.isMobile, 'MAIN-POINTS', `Breakdown (mobile): browserSearch=${browserEnarablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
+ log(this.isMobile, 'MAIN-POINTS', 'All mobile earnable buckets are zero. Causes: mobile searches already maxed, daily set finished, or daily rollover not reached yet. You can force execution by setting execution.runOnZeroPoints=true.', 'log', 'yellow')
+ }
+
// If runOnZeroPoints is false and 0 points to earn, don't continue
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
@@ -434,7 +928,10 @@ export class MicrosoftRewardsBot {
private async sendConclusion(summaries: AccountSummary[]) {
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
const cfg = this.config
- if (!cfg.conclusionWebhook || !cfg.conclusionWebhook.enabled) return
+
+ const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
+ const ntfyEnabled = !!(cfg.ntfy && cfg.ntfy.enabled)
+ const webhookEnabled = !!(cfg.webhook && cfg.webhook.enabled)
const totalAccounts = summaries.length
if (totalAccounts === 0) return
@@ -444,58 +941,268 @@ export class MicrosoftRewardsBot {
let totalEnd = 0
let totalDuration = 0
let accountsWithErrors = 0
+ let successes = 0
+
+ type DiscordField = { name: string; value: string; inline?: boolean }
+ type DiscordFooter = { text: string }
+ type DiscordEmbed = {
+ title?: string
+ description?: string
+ color?: number
+ fields?: DiscordField[]
+ timestamp?: string
+ footer?: DiscordFooter
+ }
+
+ const accountFields: DiscordField[] = []
+ const accountLines: string[] = []
- const accountFields: any[] = []
for (const s of summaries) {
totalCollected += s.totalCollected
totalInitial += s.initialTotal
totalEnd += s.endTotal
totalDuration += s.durationMs
if (s.errors.length) accountsWithErrors++
+ else successes++
- const statusEmoji = s.errors.length ? 'โ ๏ธ' : 'โ
'
+ const statusEmoji = s.banned?.status ? '๐ซ' : (s.errors.length ? 'โ ๏ธ' : 'โ
')
const diff = s.totalCollected
const duration = formatDuration(s.durationMs)
+
+ // Build embed fields (Discord)
const valueLines: string[] = [
`Points: ${s.initialTotal} โ ${s.endTotal} ( +${diff} )`,
`Breakdown: ๐ฅ๏ธ ${s.desktopCollected} | ๐ฑ ${s.mobileCollected}`,
`Duration: โฑ๏ธ ${duration}`
]
+ if (s.banned?.status) {
+ valueLines.push(`Banned: ${s.banned.reason || 'detected by heuristics'}`)
+ }
if (s.errors.length) {
- valueLines.push(`Errors: ${s.errors.slice(0,2).join(' | ')}`)
+ valueLines.push(`Errors: ${s.errors.slice(0, 2).join(' | ')}`)
}
accountFields.push({
name: `${statusEmoji} ${s.email}`.substring(0, 256),
value: valueLines.join('\n').substring(0, 1024),
inline: false
})
+
+ // Build plain text lines (NTFY)
+ const lines = [
+ `${statusEmoji} ${s.email}`,
+ ` Points: ${s.initialTotal} โ ${s.endTotal} ( +${diff} )`,
+ ` ๐ฅ๏ธ ${s.desktopCollected} | ๐ฑ ${s.mobileCollected}`,
+ ` Duration: ${duration}`
+ ]
+ if (s.banned?.status) lines.push(` Banned: ${s.banned.reason || 'detected by heuristics'}`)
+ if (s.errors.length) lines.push(` Errors: ${s.errors.slice(0, 2).join(' | ')}`)
+ accountLines.push(lines.join('\n') + '\n')
}
const avgDuration = totalDuration / totalAccounts
- const embed = {
+
+ // Read package version (best-effort)
+ let version = 'unknown'
+ try {
+ const pkgPath = path.join(process.cwd(), 'package.json')
+ if (fs.existsSync(pkgPath)) {
+ const raw = fs.readFileSync(pkgPath, 'utf-8')
+ const pkg = JSON.parse(raw)
+ version = pkg.version || version
+ }
+ } catch { /* ignore */ }
+
+ // Discord/Webhook embeds with chunking (limits: 10 embeds/message, 25 fields/embed)
+ const MAX_EMBEDS = 10
+ const MAX_FIELDS = 25
+
+ const baseFields = [
+ {
+ name: 'Global Totals',
+ value: [
+ `Total Points: ${totalInitial} โ ${totalEnd} ( +${totalCollected} )`,
+ `Accounts: โ
${successes} โข โ ๏ธ ${accountsWithErrors} (of ${totalAccounts})`,
+ `Average Duration: ${formatDuration(avgDuration)}`,
+ `Cumulative Runtime: ${formatDuration(totalDuration)}`
+ ].join('\n')
+ }
+ ]
+
+ // Prepare embeds: first embed for totals, subsequent for accounts
+ const embeds: DiscordEmbed[] = []
+ const headerEmbed: DiscordEmbed = {
title: '๐ฏ Microsoft Rewards Summary',
description: `Processed **${totalAccounts}** account(s)${accountsWithErrors ? ` โข ${accountsWithErrors} with issues` : ''}`,
color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
- fields: [
- {
- name: 'Global Totals',
- value: [
- `Total Points: ${totalInitial} โ ${totalEnd} ( +${totalCollected} )`,
- `Average Duration: ${formatDuration(avgDuration)}`,
- `Cumulative Runtime: ${formatDuration(totalDuration)}`
- ].join('\n')
- },
- ...accountFields
- ].slice(0, 25), // Discord max 25 fields
+ fields: baseFields,
timestamp: new Date().toISOString(),
- footer: {
- text: 'Script conclusion webhook'
+ footer: { text: `Run ${this.runId}${version !== 'unknown' ? ` โข v${version}` : ''}` }
+ }
+ embeds.push(headerEmbed)
+
+ // Chunk account fields across remaining embeds
+ const fieldsPerEmbed = Math.min(MAX_FIELDS, 25)
+ const availableEmbeds = MAX_EMBEDS - embeds.length
+ const chunks: DiscordField[][] = []
+ for (let i = 0; i < accountFields.length; i += fieldsPerEmbed) {
+ chunks.push(accountFields.slice(i, i + fieldsPerEmbed))
+ }
+
+ const includedChunks = chunks.slice(0, availableEmbeds)
+ for (const [idx, chunk] of includedChunks.entries()) {
+ const chunkEmbed: DiscordEmbed = {
+ title: `Accounts ${idx * fieldsPerEmbed + 1}โ${Math.min((idx + 1) * fieldsPerEmbed, accountFields.length)}`,
+ color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
+ fields: chunk,
+ timestamp: new Date().toISOString()
+ }
+ embeds.push(chunkEmbed)
+ }
+
+ const omitted = chunks.length - includedChunks.length
+ if (omitted > 0 && embeds.length > 0) {
+ // Add a small note to the last embed about omitted accounts
+ const last = embeds[embeds.length - 1]!
+ const noteField: DiscordField = { name: 'Note', value: `And ${omitted * fieldsPerEmbed} more account entries not shown due to Discord limits.`, inline: false }
+ if (last.fields && Array.isArray(last.fields)) {
+ last.fields = [...last.fields, noteField].slice(0, MAX_FIELDS)
}
}
- // Fallback plain text (rare) & embed send
- const fallback = `Microsoft Rewards Summary\nAccounts: ${totalAccounts}\nTotal: ${totalInitial} -> ${totalEnd} (+${totalCollected})\nRuntime: ${formatDuration(totalDuration)}`
- await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
+ // NTFY-compatible plain text (includes per-account breakdown)
+ const fallback = [
+ 'Microsoft Rewards Summary',
+ `Accounts: ${totalAccounts}${accountsWithErrors ? ` โข ${accountsWithErrors} with issues` : ''}`,
+ `Total: ${totalInitial} -> ${totalEnd} (+${totalCollected})`,
+ `Average Duration: ${formatDuration(avgDuration)}`,
+ `Cumulative Runtime: ${formatDuration(totalDuration)}`,
+ '',
+ ...accountLines
+ ].join('\n')
+
+ // Send both when any channel is enabled: Discord gets embeds, NTFY gets fallback
+ if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
+ await ConclusionWebhook(cfg, fallback, { embeds })
+ }
+
+ // Write local JSON report for observability
+ try {
+ const fs = await import('fs')
+ const path = await import('path')
+ const now = new Date()
+ const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
+ const baseDir = path.join(process.cwd(), 'reports', day)
+ if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
+ const file = path.join(baseDir, `summary_${this.runId}.json`)
+ const payload = {
+ runId: this.runId,
+ timestamp: now.toISOString(),
+ totals: { totalCollected, totalInitial, totalEnd, totalDuration, totalAccounts, accountsWithErrors },
+ perAccount: summaries
+ }
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8')
+ log('main','REPORT',`Saved report to ${file}`)
+ } catch (e) {
+ log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
+ }
+
+ // Optionally cleanup old diagnostics folders
+ try {
+ const days = cfg.diagnostics?.retentionDays
+ if (typeof days === 'number' && days > 0) {
+ await this.cleanupOldDiagnostics(days)
+ }
+ } catch (e) {
+ log('main','REPORT',`Failed diagnostics cleanup: ${e instanceof Error ? e.message : e}`,'warn')
+ }
+ }
+
+ /** Reserve one diagnostics slot for this run (caps captures). */
+ public tryReserveDiagSlot(maxPerRun: number): boolean {
+ if (this.diagCount >= Math.max(0, maxPerRun || 0)) return false
+ this.diagCount += 1
+ return true
+ }
+
+ /** Delete diagnostics folders older than N days under ./reports */
+ private async cleanupOldDiagnostics(retentionDays: number) {
+ const base = path.join(process.cwd(), 'reports')
+ if (!fs.existsSync(base)) return
+ const entries = fs.readdirSync(base, { withFileTypes: true })
+ const now = Date.now()
+ const keepMs = retentionDays * 24 * 60 * 60 * 1000
+ for (const e of entries) {
+ if (!e.isDirectory()) continue
+ const name = e.name // expect YYYY-MM-DD
+ const parts = name.split('-').map((n: string) => parseInt(n, 10))
+ if (parts.length !== 3 || parts.some(isNaN)) continue
+ const [yy, mm, dd] = parts
+ if (yy === undefined || mm === undefined || dd === undefined) continue
+ const dirDate = new Date(yy, mm - 1, dd).getTime()
+ if (isNaN(dirDate)) continue
+ if (now - dirDate > keepMs) {
+ const dirPath = path.join(base, name)
+ try { fs.rmSync(dirPath, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ }
+ }
+
+ // Run optional auto-update script based on configuration flags.
+ private async runAutoUpdate(): Promise {
+ const upd = this.config.update
+ if (!upd) return
+ const scriptRel = upd.scriptPath || 'setup/update/update.mjs'
+ const scriptAbs = path.join(process.cwd(), scriptRel)
+ if (!fs.existsSync(scriptAbs)) return
+
+ const args: string[] = []
+ // Git update is enabled by default (unless explicitly set to false)
+ if (upd.git !== false) args.push('--git')
+ if (upd.docker) args.push('--docker')
+ if (args.length === 0) return
+
+ await new Promise((resolve) => {
+ const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' })
+ child.on('close', () => resolve())
+ child.on('error', () => resolve())
+ })
+ }
+
+ /** Public entry-point to engage global security standby from other modules (idempotent). */
+ public async engageGlobalStandby(reason: string, email?: string): Promise {
+ try {
+ if (this.globalStandby.active) return
+ this.globalStandby = { active: true, reason }
+ const who = email || this.currentAccountEmail || 'unknown'
+ await this.sendGlobalSecurityStandbyAlert(who, reason)
+ } catch {/* ignore */}
+ }
+
+ /** Send a strong alert to all channels and mention @everyone when entering global security standby. */
+ private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise {
+ try {
+ const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
+ const title = '๐จ Global security standby engaged'
+ const desc = [
+ `Account: ${email}`,
+ `Reason: ${reason}`,
+ 'Action: Pausing all further accounts. We will not proceed until this is resolved.',
+ 'Security check by @Light'
+ ].join('\n')
+ // Mention everyone in content for Discord visibility
+ const content = '@everyone ' + title
+ await ConclusionWebhook(this.config, content, {
+ embeds: [
+ {
+ title,
+ description: desc,
+ color: 0xFF0000
+ }
+ ]
+ })
+ } catch (e) {
+ log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn')
+ }
}
}
@@ -508,10 +1215,11 @@ interface AccountSummary {
initialTotal: number
endTotal: number
errors: string[]
+ banned?: { status: boolean; reason: string }
}
-function shortErr(e: any): string {
- if (!e) return 'unknown'
+function shortErr(e: unknown): string {
+ if (e == null) return 'unknown'
if (e instanceof Error) return e.message.substring(0, 120)
const s = String(e)
return s.substring(0, 120)
@@ -531,18 +1239,60 @@ function formatDuration(ms: number): string {
}
async function main() {
+ // CommunityReporter disabled per project policy
+ // (previously: init + global hooks for uncaughtException/unhandledRejection)
const rewardsBot = new MicrosoftRewardsBot(false)
- try {
- await rewardsBot.initialize()
- await rewardsBot.run()
- } catch (error) {
- log(false, 'MAIN-ERROR', `Error running desktop bot: ${error}`, 'error')
+ const crashState = { restarts: 0 }
+ const config = rewardsBot.config
+
+ const attachHandlers = () => {
+ process.on('unhandledRejection', (reason) => {
+ log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')
+ gracefulExit(1)
+ })
+ process.on('uncaughtException', (err) => {
+ log('main','FATAL','UncaughtException: ' + err.message, 'error')
+ gracefulExit(1)
+ })
+ process.on('SIGTERM', () => gracefulExit(0))
+ process.on('SIGINT', () => gracefulExit(0))
}
+
+ const gracefulExit = (code: number) => {
+ try { rewardsBot['heartbeatTimer'] && clearInterval(rewardsBot['heartbeatTimer']) } catch { /* ignore */ }
+ if (config?.crashRecovery?.autoRestart && code !== 0) {
+ const max = config.crashRecovery.maxRestarts ?? 2
+ if (crashState.restarts < max) {
+ const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1)
+ log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow')
+ setTimeout(() => {
+ crashState.restarts++
+ bootstrap()
+ }, backoff)
+ return
+ }
+ }
+ process.exit(code)
+ }
+
+ const bootstrap = async () => {
+ try {
+ await rewardsBot.initialize()
+ await rewardsBot.run()
+ } catch (e) {
+ log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error')
+ gracefulExit(1)
+ }
+ }
+
+ attachHandlers()
+ await bootstrap()
}
// Start the bots
main().catch(error => {
log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error')
+ // CommunityReporter disabled
process.exit(1)
})
\ No newline at end of file
diff --git a/src/interface/Account.ts b/src/interface/Account.ts
index 0847f99..52bbf41 100644
--- a/src/interface/Account.ts
+++ b/src/interface/Account.ts
@@ -1,6 +1,10 @@
export interface Account {
email: string;
password: string;
+ /** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */
+ totp?: string;
+ /** Optional recovery email used to verify masked address on Microsoft login screens */
+ recoveryEmail?: string;
proxy: AccountProxy;
}
diff --git a/src/interface/ActivityHandler.ts b/src/interface/ActivityHandler.ts
new file mode 100644
index 0000000..16169f9
--- /dev/null
+++ b/src/interface/ActivityHandler.ts
@@ -0,0 +1,21 @@
+import type { MorePromotion, PromotionalItem } from './DashboardData'
+import type { Page } from 'playwright'
+
+/**
+ * Activity handler contract for solving a single dashboard activity.
+ * Implementations should be stateless (or hold only a reference to the bot)
+ * and perform all required steps on the provided page.
+ */
+export interface ActivityHandler {
+ /** Optional identifier for diagnostics */
+ id?: string
+ /**
+ * Return true if this handler knows how to process the given activity.
+ */
+ canHandle(activity: MorePromotion | PromotionalItem): boolean
+ /**
+ * Execute the activity on the provided page. The page is already
+ * navigated to the activity tab/window by the caller.
+ */
+ run(page: Page, activity: MorePromotion | PromotionalItem): Promise
+}
diff --git a/src/interface/Config.ts b/src/interface/Config.ts
index 0194f7c..b7bec5c 100644
--- a/src/interface/Config.ts
+++ b/src/interface/Config.ts
@@ -10,11 +10,23 @@ export interface Config {
searchOnBingLocalQueries: boolean;
globalTimeout: number | string;
searchSettings: ConfigSearchSettings;
+ humanization?: ConfigHumanization; // Anti-ban humanization controls
+ retryPolicy?: ConfigRetryPolicy; // Global retry/backoff policy
+ jobState?: ConfigJobState; // Persistence of per-activity checkpoints
logExcludeFunc: string[];
webhookLogExcludeFunc: string[];
+ logging?: ConfigLogging; // Preserve original logging object (for live webhook settings)
proxy: ConfigProxy;
webhook: ConfigWebhook;
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
+ ntfy: ConfigNtfy;
+ diagnostics?: ConfigDiagnostics;
+ update?: ConfigUpdate;
+ schedule?: ConfigSchedule;
+ passesPerRun?: number;
+ buyMode?: ConfigBuyMode; // Optional manual spending mode
+ vacation?: ConfigVacation; // Optional monthly contiguous off-days
+ crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
}
export interface ConfigSaveFingerprint {
@@ -28,6 +40,8 @@ export interface ConfigSearchSettings {
clickRandomResults: boolean;
searchDelay: ConfigSearchDelay;
retryMobileSearchAmount: number;
+ localFallbackCount?: number; // Number of local fallback queries to sample when trends fail
+ extraFallbackRetries?: number; // Additional mini-retry loops with fallback terms
}
export interface ConfigSearchDelay {
@@ -38,6 +52,15 @@ export interface ConfigSearchDelay {
export interface ConfigWebhook {
enabled: boolean;
url: string;
+ username?: string; // Optional override for displayed webhook name
+ avatarUrl?: string; // Optional avatar image URL
+}
+
+export interface ConfigNtfy {
+ enabled: boolean;
+ url: string;
+ topic: string;
+ authToken?: string; // Optional authentication token
}
export interface ConfigProxy {
@@ -45,6 +68,50 @@ export interface ConfigProxy {
proxyBingTerms: boolean;
}
+export interface ConfigDiagnostics {
+ enabled?: boolean; // master toggle
+ saveScreenshot?: boolean; // capture .png
+ saveHtml?: boolean; // capture .html
+ maxPerRun?: number; // cap number of captures per run
+ retentionDays?: number; // delete older diagnostic folders
+}
+
+export interface ConfigUpdate {
+ git?: boolean; // if true, run git pull + npm ci + npm run build after completion
+ docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
+ scriptPath?: string; // optional custom path to update script relative to repo root
+}
+
+export interface ConfigBuyMode {
+ enabled?: boolean; // if true, force buy mode session
+ maxMinutes?: number; // session duration cap
+}
+
+export interface ConfigSchedule {
+ enabled?: boolean;
+ time?: string; // Back-compat: accepts "HH:mm" or "h:mm AM/PM"
+ // New optional explicit times
+ time12?: string; // e.g., "9:00 AM"
+ time24?: string; // e.g., "09:00"
+ timeZone?: string; // IANA TZ e.g., "America/New_York"
+ useAmPm?: boolean; // If true, prefer time12 + AM/PM style; if false, prefer time24. If undefined, back-compat behavior.
+ runImmediatelyOnStart?: boolean; // if true, run once immediately when process starts
+}
+
+export interface ConfigVacation {
+ enabled?: boolean; // default false
+ minDays?: number; // default 3
+ maxDays?: number; // default 5
+}
+
+export interface ConfigCrashRecovery {
+ autoRestart?: boolean; // Restart the root process after fatal crash
+ maxRestarts?: number; // Max restart attempts (default 2)
+ backoffBaseMs?: number; // Base backoff before restart (default 2000)
+ restartFailedWorker?: boolean; // (future) attempt to respawn crashed worker
+ restartFailedWorkerAttempts?: number; // attempts per worker (default 1)
+}
+
export interface ConfigWorkers {
doDailySet: boolean;
doMorePromotions: boolean;
@@ -53,4 +120,60 @@ export interface ConfigWorkers {
doMobileSearch: boolean;
doDailyCheckIn: boolean;
doReadToEarn: boolean;
+ bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set
}
+
+// Anti-ban humanization
+export interface ConfigHumanization {
+ // Master toggle for Human Mode. When false, humanization is minimized.
+ enabled?: boolean;
+ // If true, stop processing remaining accounts after a ban is detected
+ stopOnBan?: boolean;
+ // If true, send an immediate webhook/NTFY alert when a ban is detected
+ immediateBanAlert?: boolean;
+ // Additional random waits between actions
+ actionDelay?: { min: number | string; max: number | string };
+ // Probability [0..1] to perform micro mouse moves per step
+ gestureMoveProb?: number;
+ // Probability [0..1] to perform tiny scrolls per step
+ gestureScrollProb?: number;
+ // Allowed execution windows (local time). Each item is "HH:mm-HH:mm".
+ // If provided, runs outside these windows will be delayed until the next allowed window.
+ allowedWindows?: string[];
+ // Randomly skip N days per week to look more human (0-7). Default 1.
+ randomOffDaysPerWeek?: number;
+}
+
+// Retry/backoff policy
+export interface ConfigRetryPolicy {
+ maxAttempts?: number; // default 3
+ baseDelay?: number | string; // default 1000ms
+ maxDelay?: number | string; // default 30s
+ multiplier?: number; // default 2
+ jitter?: number; // 0..1; default 0.2
+}
+
+// Job state persistence
+export interface ConfigJobState {
+ enabled?: boolean; // default true
+ dir?: string; // base directory; defaults to /job-state
+}
+
+// Live logging configuration
+export interface ConfigLoggingLive {
+ enabled?: boolean; // master switch for live webhook logs
+ redactEmails?: boolean; // if true, redact emails in outbound logs
+}
+
+export interface ConfigLogging {
+ excludeFunc?: string[];
+ webhookExcludeFunc?: string[];
+ live?: ConfigLoggingLive;
+ liveWebhookUrl?: string; // legacy/dedicated live webhook override
+ redactEmails?: boolean; // legacy top-level redaction flag
+ // Optional nested live.url support (already handled dynamically in Logger)
+ [key: string]: unknown; // forward compatibility
+}
+
+// CommunityHelp removed (privacy-first policy)
+
diff --git a/src/run_daily.sh b/src/run_daily.sh
deleted file mode 100755
index 6f95033..0000000
--- a/src/run_daily.sh
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
-export TZ="${TZ:-UTC}"
-
-cd /usr/src/microsoft-rewards-script
-
-LOCKFILE=/tmp/run_daily.lock
-
-# -------------------------------
-# Function: Check and fix lockfile integrity
-# -------------------------------
-self_heal_lockfile() {
- # If lockfile exists but is empty โ remove it
- if [ -f "$LOCKFILE" ]; then
- local lock_content
- lock_content=$(<"$LOCKFILE" || echo "")
-
- if [[ -z "$lock_content" ]]; then
- echo "[$(date)] [run_daily.sh] Found empty lockfile โ removing."
- rm -f "$LOCKFILE"
- return
- fi
-
- # If lockfile contains non-numeric PID โ remove it
- if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then
- echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') โ removing."
- rm -f "$LOCKFILE"
- return
- fi
-
- # If lockfile contains PID but process is dead โ remove it
- if ! kill -0 "$lock_content" 2>/dev/null; then
- echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead โ removing stale lock."
- rm -f "$LOCKFILE"
- return
- fi
- fi
-}
-
-# -------------------------------
-# Function: Acquire lock
-# -------------------------------
-acquire_lock() {
- local max_attempts=5
- local attempt=0
- local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8}
- local timeout_seconds=$((timeout_hours * 3600))
-
- while [ $attempt -lt $max_attempts ]; do
- # Try to create lock with current PID
- if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then
- echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)"
- return 0
- fi
-
- # Lock exists, validate it
- if [ -f "$LOCKFILE" ]; then
- local existing_pid
- existing_pid=$(<"$LOCKFILE" || echo "")
-
- echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'"
-
- # If lockfile content is invalid โ delete and retry
- if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then
- echo "[$(date)] [run_daily.sh] Removing invalid lockfile โ retrying..."
- rm -f "$LOCKFILE"
- continue
- fi
-
- # If process is dead โ delete and retry
- if ! kill -0 "$existing_pid" 2>/dev/null; then
- echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)"
- rm -f "$LOCKFILE"
- continue
- fi
-
- # Check process runtime โ kill if exceeded timeout
- local process_age
- if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then
- if [ "$process_age" -gt "$timeout_seconds" ]; then
- echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)"
- kill -TERM "$existing_pid" 2>/dev/null || true
- sleep 5
- kill -KILL "$existing_pid" 2>/dev/null || true
- rm -f "$LOCKFILE"
- continue
- fi
- fi
- fi
-
- echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts"
- sleep 2
- ((attempt++))
- done
-
- echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting."
- return 1
-}
-
-# -------------------------------
-# Function: Release lock
-# -------------------------------
-release_lock() {
- if [ -f "$LOCKFILE" ]; then
- local lock_pid
- lock_pid=$(<"$LOCKFILE")
- if [ "$lock_pid" = "$$" ]; then
- rm -f "$LOCKFILE"
- echo "[$(date)] [run_daily.sh] Lock released (PID: $$)"
- fi
- fi
-}
-
-# Always release lock on exit โ but only if we acquired it
-trap 'release_lock' EXIT INT TERM
-
-# -------------------------------
-# MAIN EXECUTION FLOW
-# -------------------------------
-echo "[$(date)] [run_daily.sh] Current process PID: $$"
-
-# Self-heal any broken or empty locks before proceeding
-self_heal_lockfile
-
-# Attempt to acquire the lock safely
-if ! acquire_lock; then
- exit 0
-fi
-
-# Random sleep between MIN and MAX to spread execution
-MINWAIT=${MIN_SLEEP_MINUTES:-5}
-MAXWAIT=${MAX_SLEEP_MINUTES:-50}
-MINWAIT_SEC=$((MINWAIT*60))
-MAXWAIT_SEC=$((MAXWAIT*60))
-
-if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then
- SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) ))
- echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)"
- sleep "$SLEEPTIME"
-else
- echo "[$(date)] [run_daily.sh] Skipping random sleep"
-fi
-
-# Start the actual script
-echo "[$(date)] [run_daily.sh] Starting script..."
-if npm start; then
- echo "[$(date)] [run_daily.sh] Script completed successfully."
-else
- echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
-fi
-
-echo "[$(date)] [run_daily.sh] Script finished"
-# Lock is released automatically via trap
diff --git a/src/scheduler.ts b/src/scheduler.ts
new file mode 100644
index 0000000..b3ab1c9
--- /dev/null
+++ b/src/scheduler.ts
@@ -0,0 +1,329 @@
+import { DateTime, IANAZone } from 'luxon'
+import { spawn } from 'child_process'
+import fs from 'fs'
+import path from 'path'
+import { MicrosoftRewardsBot } from './index'
+import { loadConfig } from './util/Load'
+import { log } from './util/Logger'
+import type { Config } from './interface/Config'
+
+function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } {
+ const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
+ // Determine source string
+ let src = ''
+ if (typeof schedule?.useAmPm === 'boolean') {
+ if (schedule.useAmPm) src = (schedule.time12 || schedule.time || '').trim()
+ else src = (schedule.time24 || schedule.time || '').trim()
+ } else {
+ // Back-compat: prefer time if present; else time24 or time12
+ src = (schedule?.time || schedule?.time24 || schedule?.time12 || '').trim()
+ }
+ // Try to parse 24h first: HH:mm
+ const m24 = src.match(/^\s*(\d{1,2}):(\d{2})\s*$/i)
+ if (m24) {
+ const hh = Math.max(0, Math.min(23, parseInt(m24[1]!, 10)))
+ const mm = Math.max(0, Math.min(59, parseInt(m24[2]!, 10)))
+ return { tz, hour: hh, minute: mm }
+ }
+ // Parse 12h with AM/PM: h:mm AM or h AM
+ const m12 = src.match(/^\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*$/i)
+ if (m12) {
+ let hh = parseInt(m12[1]!, 10)
+ const mm = m12[2] ? parseInt(m12[2]!, 10) : 0
+ const ampm = m12[3]!.toUpperCase()
+ if (hh === 12) hh = 0
+ if (ampm === 'PM') hh += 12
+ hh = Math.max(0, Math.min(23, hh))
+ const m = Math.max(0, Math.min(59, mm))
+ return { tz, hour: hh, minute: m }
+ }
+ // Fallback: default 09:00
+ return { tz, hour: 9, minute: 0 }
+}
+
+function parseTargetToday(now: Date, schedule: Config['schedule'] | undefined) {
+ const { tz, hour, minute } = resolveTimeParts(schedule)
+ const dtn = DateTime.fromJSDate(now, { zone: tz })
+ return dtn.set({ hour, minute, second: 0, millisecond: 0 })
+}
+
+async function runOnePass(): Promise {
+ const bot = new MicrosoftRewardsBot(false)
+ await bot.initialize()
+ await bot.run()
+}
+
+/**
+ * Run a single pass either in-process or as a child process (default),
+ * with a watchdog timeout to kill stuck runs.
+ */
+async function runOnePassWithWatchdog(): Promise {
+ // Heartbeat-aware watchdog configuration
+ // If a child is actively updating its heartbeat file, we allow it to run beyond the legacy timeout.
+ // Defaults are generous to allow first-day passes to finish searches with delays.
+ const staleHeartbeatMin = Number(
+ process.env.SCHEDULER_STALE_HEARTBEAT_MINUTES || process.env.SCHEDULER_PASS_TIMEOUT_MINUTES || 30
+ )
+ const graceMin = Number(process.env.SCHEDULER_HEARTBEAT_GRACE_MINUTES || 15)
+ const hardcapMin = Number(process.env.SCHEDULER_PASS_HARDCAP_MINUTES || 480) // 8 hours
+ const checkEveryMs = 60_000 // check once per minute
+
+ // Fork per pass: safer because we can terminate a stuck child without killing the scheduler
+ const forkPerPass = String(process.env.SCHEDULER_FORK_PER_PASS || 'true').toLowerCase() !== 'false'
+
+ if (!forkPerPass) {
+ // In-process fallback (cannot forcefully stop if truly stuck)
+ await log('main', 'SCHEDULER', `Starting pass in-process (grace ${graceMin}m โข stale ${staleHeartbeatMin}m โข hardcap ${hardcapMin}m). Cannot force-kill if stuck.`)
+ // No true watchdog possible in-process; just run
+ await runOnePass()
+ return
+ }
+
+ // Child process execution
+ const indexJs = path.join(__dirname, 'index.js')
+ await log('main', 'SCHEDULER', `Spawning child for pass: ${process.execPath} ${indexJs}`)
+
+ // Prepare heartbeat file path and pass to child
+ const cfg = loadConfig() as Config
+ const baseDir = path.join(process.cwd(), cfg.sessionPath || 'sessions')
+ const hbFile = path.join(baseDir, `heartbeat_${Date.now()}.lock`)
+ try { fs.mkdirSync(baseDir, { recursive: true }) } catch { /* ignore */ }
+
+ await new Promise((resolve) => {
+ const child = spawn(process.execPath, [indexJs], { stdio: 'inherit', env: { ...process.env, SCHEDULER_HEARTBEAT_FILE: hbFile } })
+ let finished = false
+ const startedAt = Date.now()
+
+ const killChild = async (signal: NodeJS.Signals) => {
+ try {
+ await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn')
+ child.kill(signal)
+ } catch { /* ignore */ }
+ }
+
+ const timer = setInterval(() => {
+ if (finished) return
+ const now = Date.now()
+ const runtimeMin = Math.floor((now - startedAt) / 60000)
+ // Hard cap: always terminate if exceeded
+ if (runtimeMin >= hardcapMin) {
+ log('main', 'SCHEDULER', `Pass exceeded hard cap of ${hardcapMin} minutes; terminating...`, 'warn')
+ void killChild('SIGTERM')
+ setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
+ return
+ }
+ // Before grace, don't judge
+ if (runtimeMin < graceMin) return
+ // Check heartbeat freshness
+ try {
+ const st = fs.statSync(hbFile)
+ const mtimeMs = st.mtimeMs
+ const ageMin = Math.floor((now - mtimeMs) / 60000)
+ if (ageMin >= staleHeartbeatMin) {
+ log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${staleHeartbeatMin}m). Terminating child...`, 'warn')
+ void killChild('SIGTERM')
+ setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
+ }
+ } catch {
+ // If file missing after grace, consider stale
+ log('main', 'SCHEDULER', 'Heartbeat file missing after grace. Terminating child...', 'warn')
+ void killChild('SIGTERM')
+ setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000)
+ }
+ }, checkEveryMs)
+
+ child.on('exit', async (code, signal) => {
+ finished = true
+ clearInterval(timer)
+ // Cleanup heartbeat file
+ try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
+ if (signal) {
+ await log('main', 'SCHEDULER', `Child exited due to signal: ${signal}`, 'warn')
+ } else if (code && code !== 0) {
+ await log('main', 'SCHEDULER', `Child exited with non-zero code: ${code}`, 'warn')
+ } else {
+ await log('main', 'SCHEDULER', 'Child pass completed successfully')
+ }
+ resolve()
+ })
+
+ child.on('error', async (err) => {
+ finished = true
+ clearInterval(timer)
+ try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ }
+ await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error')
+ resolve()
+ })
+ })
+}
+
+async function runPasses(passes: number): Promise {
+ const n = Math.max(1, Math.floor(passes || 1))
+ for (let i = 1; i <= n; i++) {
+ await log('main', 'SCHEDULER', `Starting pass ${i}/${n}`)
+ const started = Date.now()
+ await runOnePassWithWatchdog()
+ const took = Date.now() - started
+ const sec = Math.max(1, Math.round(took / 1000))
+ await log('main', 'SCHEDULER', `Completed pass ${i}/${n}`)
+ await log('main', 'SCHEDULER', `Pass ${i} duration: ${sec}s`)
+ }
+}
+
+async function main() {
+ const cfg = loadConfig() as Config & { schedule?: { enabled?: boolean; time?: string; timeZone?: string; runImmediatelyOnStart?: boolean } }
+ const schedule = cfg.schedule || { enabled: false }
+ const passes = typeof cfg.passesPerRun === 'number' ? cfg.passesPerRun : 1
+ const offPerWeek = Math.max(0, Math.min(7, Number(cfg.humanization?.randomOffDaysPerWeek ?? 1)))
+ let offDays: number[] = [] // 1..7 ISO weekday
+ let offWeek: number | null = null
+ type VacRange = { start: string; end: string } | null
+ let vacMonth: string | null = null // 'yyyy-LL'
+ let vacRange: VacRange = null // ISO dates 'yyyy-LL-dd'
+
+ const refreshOffDays = async (now: { weekNumber: number }) => {
+ if (offPerWeek <= 0) { offDays = []; offWeek = null; return }
+ const week = now.weekNumber
+ if (offWeek === week && offDays.length) return
+ // choose distinct weekdays [1..7]
+ const pool = [1,2,3,4,5,6,7]
+ const chosen: number[] = []
+ for (let i=0;ia-b)
+ offWeek = week
+ await log('main','SCHEDULER',`Selected random off-days this week (ISO): ${offDays.join(', ')}`,'warn')
+ }
+
+ const chooseVacationRange = async (now: typeof DateTime.prototype) => {
+ // Only when enabled
+ if (!cfg.vacation?.enabled) { vacRange = null; vacMonth = null; return }
+ const monthKey = now.toFormat('yyyy-LL')
+ if (vacMonth === monthKey && vacRange) return
+ // Determine month days and choose contiguous block
+ const monthStart = now.startOf('month')
+ const monthEnd = now.endOf('month')
+ const totalDays = monthEnd.day
+ const minD = Math.max(1, Math.min(28, Number(cfg.vacation.minDays ?? 3)))
+ const maxD = Math.max(minD, Math.min(31, Number(cfg.vacation.maxDays ?? 5)))
+ const span = (minD === maxD) ? minD : (minD + Math.floor(Math.random() * (maxD - minD + 1)))
+ const latestStart = Math.max(1, totalDays - span + 1)
+ const startDay = 1 + Math.floor(Math.random() * latestStart)
+ const start = monthStart.set({ day: startDay })
+ const end = start.plus({ days: span - 1 })
+ vacMonth = monthKey
+ vacRange = { start: start.toFormat('yyyy-LL-dd'), end: end.toFormat('yyyy-LL-dd') }
+ await log('main','SCHEDULER',`Selected vacation block this month: ${vacRange.start} โ ${vacRange.end} (${span} day(s))`,'warn')
+ }
+
+ if (!schedule.enabled) {
+ await log('main', 'SCHEDULER', 'Schedule disabled; running once then exit')
+ await runPasses(passes)
+ process.exit(0)
+ }
+
+ const tz = (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC'
+ // Default to false to avoid unexpected immediate runs
+ const runImmediate = schedule.runImmediatelyOnStart === true
+ let running = false
+
+ // Optional initial jitter before the first run (to vary start time)
+ const initJitterMin = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MIN || process.env.SCHEDULER_INITIAL_JITTER_MIN || 0)
+ const initJitterMax = Number(process.env.SCHEDULER_INITIAL_JITTER_MINUTES_MAX || process.env.SCHEDULER_INITIAL_JITTER_MAX || 0)
+ const initialJitterBounds: [number, number] = [isFinite(initJitterMin) ? initJitterMin : 0, isFinite(initJitterMax) ? initJitterMax : 0]
+ const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0)
+
+ if (runImmediate && !running) {
+ running = true
+ if (applyInitialJitter) {
+ const min = Math.max(0, Math.min(initialJitterBounds[0], initialJitterBounds[1]))
+ const max = Math.max(0, Math.max(initialJitterBounds[0], initialJitterBounds[1]))
+ const jitterSec = (min === max) ? min * 60 : (min * 60 + Math.floor(Math.random() * ((max - min) * 60)))
+ if (jitterSec > 0) {
+ await log('main', 'SCHEDULER', `Initial jitter: delaying first run by ${Math.round(jitterSec / 60)} minute(s) (${jitterSec}s)`, 'warn')
+ await new Promise((r) => setTimeout(r, jitterSec * 1000))
+ }
+ }
+ const nowDT = DateTime.local().setZone(tz)
+ await chooseVacationRange(nowDT)
+ await refreshOffDays(nowDT)
+ const todayIso = nowDT.toFormat('yyyy-LL-dd')
+ const vr = vacRange as { start: string; end: string } | null
+ const isVacationToday = !!(vr && todayIso >= vr.start && todayIso <= vr.end)
+ if (isVacationToday) {
+ await log('main','SCHEDULER',`Skipping immediate run: vacation day (${todayIso})`,'warn')
+ } else if (offDays.includes(nowDT.weekday)) {
+ await log('main','SCHEDULER',`Skipping immediate run: off-day (weekday ${nowDT.weekday})`,'warn')
+ } else {
+ await runPasses(passes)
+ }
+ running = false
+ }
+
+ for (;;) {
+ const now = new Date()
+ const targetToday = parseTargetToday(now, schedule)
+ let next = targetToday
+ const nowDT = DateTime.fromJSDate(now, { zone: targetToday.zone })
+
+ if (nowDT >= targetToday) {
+ next = targetToday.plus({ days: 1 })
+ }
+
+ let ms = Math.max(0, next.toMillis() - nowDT.toMillis())
+
+ // Optional daily jitter to further randomize the exact start time each day
+ const dailyJitterMin = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MIN || process.env.SCHEDULER_DAILY_JITTER_MIN || 0)
+ const dailyJitterMax = Number(process.env.SCHEDULER_DAILY_JITTER_MINUTES_MAX || process.env.SCHEDULER_DAILY_JITTER_MAX || 0)
+ const djMin = isFinite(dailyJitterMin) ? dailyJitterMin : 0
+ const djMax = isFinite(dailyJitterMax) ? dailyJitterMax : 0
+ let extraMs = 0
+ if (djMin > 0 || djMax > 0) {
+ const mn = Math.max(0, Math.min(djMin, djMax))
+ const mx = Math.max(0, Math.max(djMin, djMax))
+ const jitterSec = (mn === mx) ? mn * 60 : (mn * 60 + Math.floor(Math.random() * ((mx - mn) * 60)))
+ extraMs = jitterSec * 1000
+ ms += extraMs
+ }
+
+ const human = next.toFormat('yyyy-LL-dd HH:mm ZZZZ')
+ const totalSec = Math.round(ms / 1000)
+ if (extraMs > 0) {
+ await log('main', 'SCHEDULER', `Next run at ${human} plus daily jitter (+${Math.round(extraMs/60000)}m) โ in ${totalSec}s`)
+ } else {
+ await log('main', 'SCHEDULER', `Next run at ${human} (in ${totalSec}s)`)
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, ms))
+
+ const nowRun = DateTime.local().setZone(tz)
+ await chooseVacationRange(nowRun)
+ await refreshOffDays(nowRun)
+ const todayIso2 = nowRun.toFormat('yyyy-LL-dd')
+ const vr2 = vacRange as { start: string; end: string } | null
+ const isVacation = !!(vr2 && todayIso2 >= vr2.start && todayIso2 <= vr2.end)
+ if (isVacation) {
+ await log('main','SCHEDULER',`Skipping scheduled run: vacation day (${todayIso2})`,'warn')
+ continue
+ }
+ if (offDays.includes(nowRun.weekday)) {
+ await log('main','SCHEDULER',`Skipping scheduled run: off-day (weekday ${nowRun.weekday})`,'warn')
+ continue
+ }
+ if (!running) {
+ running = true
+ await runPasses(passes)
+ running = false
+ } else {
+ await log('main','SCHEDULER','Skipped scheduled trigger because a pass is already running','warn')
+ }
+ }
+}
+
+main().catch((e) => {
+ console.error(e)
+ process.exit(1)
+})
diff --git a/src/types/luxon.d.ts b/src/types/luxon.d.ts
new file mode 100644
index 0000000..002906e
--- /dev/null
+++ b/src/types/luxon.d.ts
@@ -0,0 +1,7 @@
+/* Minimal ambient declarations to unblock TypeScript when @types/luxon not present. */
+declare module 'luxon' {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ export const DateTime: any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ export const IANAZone: any
+}
diff --git a/src/util/AdaptiveThrottler.ts b/src/util/AdaptiveThrottler.ts
new file mode 100644
index 0000000..af1f0c9
--- /dev/null
+++ b/src/util/AdaptiveThrottler.ts
@@ -0,0 +1,25 @@
+export class AdaptiveThrottler {
+ private errorCount = 0
+ private successCount = 0
+ private window: Array<{ ok: boolean; at: number }> = []
+ private readonly maxWindow = 50
+
+ record(ok: boolean) {
+ this.window.push({ ok, at: Date.now() })
+ if (ok) this.successCount++
+ else this.errorCount++
+ if (this.window.length > this.maxWindow) {
+ const removed = this.window.shift()
+ if (removed) removed.ok ? this.successCount-- : this.errorCount--
+ }
+ }
+
+ /** Return a multiplier to apply to waits (1 = normal). */
+ getDelayMultiplier(): number {
+ const total = Math.max(1, this.successCount + this.errorCount)
+ const errRatio = this.errorCount / total
+ // 0% errors -> 1x; 50% errors -> ~1.8x; 80% -> ~2.5x (cap)
+ const mult = 1 + Math.min(1.5, errRatio * 2)
+ return Number(mult.toFixed(2))
+ }
+}
diff --git a/src/util/Axios.ts b/src/util/Axios.ts
index 98c9b5b..c300f9a 100644
--- a/src/util/Axios.ts
+++ b/src/util/Axios.ts
@@ -42,7 +42,21 @@ class AxiosClient {
return bypassInstance.request(config)
}
- return this.instance.request(config)
+ try {
+ return await this.instance.request(config)
+ } catch (err: unknown) {
+ // If proxied request fails with common proxy/network errors, retry once without proxy
+ const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
+ const code = e?.code || e?.cause?.code
+ const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
+ const msg = String(e?.message || '')
+ const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
+ if (!bypassProxy && (isNetErr || looksLikeProxyIssue)) {
+ const bypassInstance = axios.create()
+ return bypassInstance.request(config)
+ }
+ throw err
+ }
}
}
diff --git a/src/util/BanDetector.ts b/src/util/BanDetector.ts
new file mode 100644
index 0000000..ebc19ef
--- /dev/null
+++ b/src/util/BanDetector.ts
@@ -0,0 +1,16 @@
+export type BanStatus = { status: boolean; reason: string }
+
+const BAN_PATTERNS: Array<{ re: RegExp; reason: string }> = [
+ { re: /suspend|suspended|suspension/i, reason: 'account suspended' },
+ { re: /locked|lockout|serviceabuse|abuse/i, reason: 'locked or service abuse detected' },
+ { re: /unusual.*activity|unusual activity/i, reason: 'unusual activity prompts' },
+ { re: /verify.*identity|identity.*verification/i, reason: 'identity verification required' }
+]
+
+export function detectBanReason(input: unknown): BanStatus {
+ const s = input instanceof Error ? (input.message || '') : String(input || '')
+ for (const p of BAN_PATTERNS) {
+ if (p.re.test(s)) return { status: true, reason: p.reason }
+ }
+ return { status: false, reason: '' }
+}
diff --git a/src/util/ConclusionWebhook.ts b/src/util/ConclusionWebhook.ts
index 67a4390..a0c9e7c 100644
--- a/src/util/ConclusionWebhook.ts
+++ b/src/util/ConclusionWebhook.ts
@@ -1,32 +1,109 @@
import axios from 'axios'
-
import { Config } from '../interface/Config'
+import { Ntfy } from './Ntfy'
+
+// Light obfuscation of the avatar URL (base64). Prevents casual editing in config.
+const AVATAR_B64 = 'aHR0cHM6Ly9tZWRpYS5kaXNjb3JkYXBwLm5ldC9hdHRhY2htZW50cy8xNDIxMTYzOTUyOTcyMzY5OTMxLzE0MjExNjQxNDU5OTQyNDAxMTAvbXNuLnBuZz93aWR0aD01MTImZWlnaHQ9NTEy'
+function getAvatarUrl(): string {
+ try { return Buffer.from(AVATAR_B64, 'base64').toString('utf-8') } catch { return '' }
+}
+
+type WebhookContext = 'summary' | 'ban' | 'security' | 'compromised' | 'spend' | 'error' | 'default'
+
+function pickUsername(ctx: WebhookContext, fallbackColor?: number): string {
+ switch (ctx) {
+ case 'summary': return 'Summary'
+ case 'ban': return 'Ban'
+ case 'security': return 'Security'
+ case 'compromised': return 'Pirate'
+ case 'spend': return 'Spend'
+ case 'error': return 'Error'
+ default: return fallbackColor === 0xFF0000 ? 'Error' : 'Rewards'
+ }
+}
+
+interface DiscordField { name: string; value: string; inline?: boolean }
+interface DiscordEmbed {
+ title?: string
+ description?: string
+ color?: number
+ fields?: DiscordField[]
+}
interface ConclusionPayload {
content?: string
- embeds?: any[]
+ embeds?: DiscordEmbed[]
+ context?: WebhookContext
}
/**
- * Send a final structured summary to the dedicated conclusion webhook (if enabled),
- * otherwise do nothing. Does NOT fallback to the normal logging webhook to avoid spam.
+ * Send a final structured summary to the configured webhook,
+ * and optionally mirror a plain-text summary to NTFY.
+ *
+ * This preserves existing webhook behavior while adding NTFY
+ * as a separate, optional channel.
*/
-export async function ConclusionWebhook(configData: Config, content: string, embed?: ConclusionPayload) {
- const webhook = configData.conclusionWebhook
+export async function ConclusionWebhook(config: Config, content: string, payload?: ConclusionPayload) {
+ // Send to both webhooks when available
+ const hasConclusion = !!(config.conclusionWebhook?.enabled && config.conclusionWebhook.url)
+ const hasWebhook = !!(config.webhook?.enabled && config.webhook.url)
+ const sameTarget = hasConclusion && hasWebhook && config.conclusionWebhook!.url === config.webhook!.url
- if (!webhook || !webhook.enabled || webhook.url.length < 10) return
+ const body: ConclusionPayload & { username?: string; avatar_url?: string } = {}
+ if (payload?.embeds) body.embeds = payload.embeds
+ if (content && content.trim()) body.content = content
+ const firstColor = payload?.embeds && payload.embeds[0]?.color
+ const ctx: WebhookContext = payload?.context || (firstColor === 0xFF0000 ? 'error' : 'default')
+ body.username = pickUsername(ctx, firstColor)
+ body.avatar_url = getAvatarUrl()
- const body: ConclusionPayload = embed?.embeds ? { embeds: embed.embeds } : { content }
- if (content && !body.content && !body.embeds) body.content = content
-
- const request = {
- method: 'POST',
- url: webhook.url,
- headers: {
- 'Content-Type': 'application/json'
- },
- data: body
+ // Post to conclusion webhook if configured
+ const postWithRetry = async (url: string, label: string) => {
+ const max = 2
+ let lastErr: unknown = null
+ for (let attempt = 1; attempt <= max; attempt++) {
+ try {
+ await axios.post(url, body, { headers: { 'Content-Type': 'application/json' }, timeout: 15000 })
+ console.log(`[Webhook:${label}] summary sent (attempt ${attempt}).`)
+ return
+ } catch (e) {
+ lastErr = e
+ if (attempt === max) break
+ await new Promise(r => setTimeout(r, 1000 * attempt))
+ }
+ }
+ console.error(`[Webhook:${label}] failed after ${max} attempts:`, lastErr)
}
- await axios(request).catch(() => { })
+ if (hasConclusion) {
+ await postWithRetry(config.conclusionWebhook!.url, sameTarget ? 'conclusion+primary' : 'conclusion')
+ }
+ if (hasWebhook && !sameTarget) {
+ await postWithRetry(config.webhook!.url, 'primary')
+ }
+
+ // NTFY: mirror a plain text summary (optional)
+ if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
+ let message = content || ''
+ if (!message && payload?.embeds && payload.embeds.length > 0) {
+ const e: DiscordEmbed = payload.embeds[0]!
+ const title = e.title ? `${e.title}\n` : ''
+ const desc = e.description ? `${e.description}\n` : ''
+ const totals = e.fields && e.fields[0]?.value ? `\n${e.fields[0].value}\n` : ''
+ message = `${title}${desc}${totals}`.trim()
+ }
+ if (!message) message = 'Microsoft Rewards run complete.'
+ // Choose NTFY level based on embed color (yellow = warn)
+ let embedColor: number | undefined
+ if (payload?.embeds && payload.embeds.length > 0) {
+ embedColor = payload.embeds[0]!.color
+ }
+ const ntfyType = embedColor === 0xFFAA00 ? 'warn' : 'log'
+ try {
+ await Ntfy(message, ntfyType)
+ console.log('Conclusion summary sent to NTFY.')
+ } catch (err) {
+ console.error('Failed to send conclusion summary to NTFY:', err)
+ }
+ }
}
diff --git a/src/util/Humanizer.ts b/src/util/Humanizer.ts
new file mode 100644
index 0000000..c8e71c2
--- /dev/null
+++ b/src/util/Humanizer.ts
@@ -0,0 +1,54 @@
+import { Page } from 'rebrowser-playwright'
+import Util from './Utils'
+import type { ConfigHumanization } from '../interface/Config'
+
+export class Humanizer {
+ private util: Util
+ private cfg: ConfigHumanization | undefined
+
+ constructor(util: Util, cfg?: ConfigHumanization) {
+ this.util = util
+ this.cfg = cfg
+ }
+
+ async microGestures(page: Page): Promise {
+ if (this.cfg && this.cfg.enabled === false) return
+ const moveProb = this.cfg?.gestureMoveProb ?? 0.4
+ const scrollProb = this.cfg?.gestureScrollProb ?? 0.2
+ try {
+ if (Math.random() < moveProb) {
+ const x = Math.floor(Math.random() * 40) + 5
+ const y = Math.floor(Math.random() * 30) + 5
+ await page.mouse.move(x, y, { steps: 2 }).catch(() => {})
+ }
+ if (Math.random() < scrollProb) {
+ const dy = (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 150) + 50)
+ await page.mouse.wheel(0, dy).catch(() => {})
+ }
+ } catch {/* noop */}
+ }
+
+ async actionPause(): Promise {
+ if (this.cfg && this.cfg.enabled === false) return
+ const defMin = 150
+ const defMax = 450
+ let min = defMin
+ let max = defMax
+ if (this.cfg?.actionDelay) {
+ const parse = (v: number | string) => {
+ if (typeof v === 'number') return v
+ try {
+ const n = this.util.stringToMs(String(v))
+ return Math.max(0, Math.min(n, 10_000))
+ } catch { return defMin }
+ }
+ min = parse(this.cfg.actionDelay.min)
+ max = parse(this.cfg.actionDelay.max)
+ if (min > max) [min, max] = [max, min]
+ max = Math.min(max, 5_000)
+ }
+ await this.util.wait(this.util.randomNumber(min, max))
+ }
+}
+
+export default Humanizer
diff --git a/src/util/JobState.ts b/src/util/JobState.ts
new file mode 100644
index 0000000..3595f13
--- /dev/null
+++ b/src/util/JobState.ts
@@ -0,0 +1,58 @@
+import fs from 'fs'
+import path from 'path'
+import type { Config } from '../interface/Config'
+
+type DayState = {
+ doneOfferIds: string[]
+}
+
+type FileState = {
+ days: Record
+}
+
+export class JobState {
+ private baseDir: string
+
+ constructor(cfg: Config) {
+ const dir = cfg.jobState?.dir || path.join(process.cwd(), cfg.sessionPath, 'job-state')
+ this.baseDir = dir
+ if (!fs.existsSync(this.baseDir)) fs.mkdirSync(this.baseDir, { recursive: true })
+ }
+
+ private fileFor(email: string): string {
+ const safe = email.replace(/[^a-z0-9._-]/gi, '_')
+ return path.join(this.baseDir, `${safe}.json`)
+ }
+
+ private load(email: string): FileState {
+ const file = this.fileFor(email)
+ if (!fs.existsSync(file)) return { days: {} }
+ try {
+ const raw = fs.readFileSync(file, 'utf-8')
+ const parsed = JSON.parse(raw)
+ return parsed && typeof parsed === 'object' && parsed.days ? parsed as FileState : { days: {} }
+ } catch { return { days: {} } }
+ }
+
+ private save(email: string, state: FileState): void {
+ const file = this.fileFor(email)
+ fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8')
+ }
+
+ isDone(email: string, day: string, offerId: string): boolean {
+ const st = this.load(email)
+ const d = st.days[day]
+ if (!d) return false
+ return d.doneOfferIds.includes(offerId)
+ }
+
+ markDone(email: string, day: string, offerId: string): void {
+ const st = this.load(email)
+ if (!st.days[day]) st.days[day] = { doneOfferIds: [] }
+ const d = st.days[day]
+ if (!d.doneOfferIds.includes(offerId)) d.doneOfferIds.push(offerId)
+ this.save(email, st)
+ }
+}
+
+export default JobState
diff --git a/src/util/Load.ts b/src/util/Load.ts
index 9667274..145ef96 100644
--- a/src/util/Load.ts
+++ b/src/util/Load.ts
@@ -8,38 +8,274 @@ import { Account } from '../interface/Account'
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 {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const n: any = (raw as any) || {}
+
+ // Browser / execution
+ const headless = n.browser?.headless ?? n.headless ?? false
+ const globalTimeout = n.browser?.globalTimeout ?? n.globalTimeout ?? '30s'
+ 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
+ const searchSettingsSrc = n.search?.settings ?? n.searchSettings ?? {}
+ const delaySrc = searchSettingsSrc.delay ?? searchSettingsSrc.searchDelay ?? { min: '3min', max: '5min' }
+ const searchSettings = {
+ useGeoLocaleQueries: !!(searchSettingsSrc.useGeoLocaleQueries ?? false),
+ scrollRandomResults: !!(searchSettingsSrc.scrollRandomResults ?? false),
+ clickRandomResults: !!(searchSettingsSrc.clickRandomResults ?? false),
+ retryMobileSearchAmount: Number(searchSettingsSrc.retryMobileSearchAmount ?? 2),
+ searchDelay: {
+ min: delaySrc.min ?? '3min',
+ max: delaySrc.max ?? '5min'
+ },
+ localFallbackCount: Number(searchSettingsSrc.localFallbackCount ?? 25),
+ extraFallbackRetries: Number(searchSettingsSrc.extraFallbackRetries ?? 1)
+ }
+
+ // Workers
+ const workers = n.workers ?? {
+ doDailySet: true,
+ doMorePromotions: true,
+ doPunchCards: true,
+ doDesktopSearch: true,
+ doMobileSearch: true,
+ doDailyCheckIn: true,
+ doReadToEarn: true,
+ bundleDailySetWithSearch: false
+ }
+ // Ensure missing flag gets a default
+ if (typeof workers.bundleDailySetWithSearch !== 'boolean') workers.bundleDailySetWithSearch = false
+
+ // Logging
+ const logging = n.logging ?? {}
+ const logExcludeFunc = Array.isArray(logging.excludeFunc) ? logging.excludeFunc : (n.logExcludeFunc ?? [])
+ const webhookLogExcludeFunc = Array.isArray(logging.webhookExcludeFunc) ? logging.webhookExcludeFunc : (n.webhookLogExcludeFunc ?? [])
+
+ // Notifications
+ const notifications = n.notifications ?? {}
+ const webhook = notifications.webhook ?? n.webhook ?? { enabled: false, url: '' }
+ const conclusionWebhook = notifications.conclusionWebhook ?? n.conclusionWebhook ?? { enabled: false, url: '' }
+ const ntfy = notifications.ntfy ?? n.ntfy ?? { enabled: false, url: '', topic: '', authToken: '' }
+
+ // Buy Mode
+ const buyMode = n.buyMode ?? {}
+ const buyModeEnabled = typeof buyMode.enabled === 'boolean' ? buyMode.enabled : false
+ const buyModeMax = typeof buyMode.maxMinutes === 'number' ? buyMode.maxMinutes : 45
+
+ // Fingerprinting
+ const saveFingerprint = (n.fingerprinting?.saveFingerprint ?? n.saveFingerprint) ?? { mobile: false, desktop: false }
+
+ // Humanization defaults (single on/off)
+ if (!n.humanization) n.humanization = {}
+ if (typeof n.humanization.enabled !== 'boolean') n.humanization.enabled = true
+ if (typeof n.humanization.stopOnBan !== 'boolean') n.humanization.stopOnBan = false
+ if (typeof n.humanization.immediateBanAlert !== 'boolean') n.humanization.immediateBanAlert = true
+ if (typeof n.humanization.randomOffDaysPerWeek !== 'number') {
+ n.humanization.randomOffDaysPerWeek = 1
+ }
+ // Strong default gestures when enabled (explicit values still win)
+ if (typeof n.humanization.gestureMoveProb !== 'number') {
+ n.humanization.gestureMoveProb = n.humanization.enabled === false ? 0 : 0.5
+ }
+ if (typeof n.humanization.gestureScrollProb !== 'number') {
+ n.humanization.gestureScrollProb = n.humanization.enabled === false ? 0 : 0.25
+ }
+
+ // Vacation mode (monthly contiguous off-days)
+ if (!n.vacation) n.vacation = {}
+ if (typeof n.vacation.enabled !== 'boolean') n.vacation.enabled = false
+ const vMin = Number(n.vacation.minDays)
+ const vMax = Number(n.vacation.maxDays)
+ n.vacation.minDays = isFinite(vMin) && vMin > 0 ? Math.floor(vMin) : 3
+ n.vacation.maxDays = isFinite(vMax) && vMax > 0 ? Math.floor(vMax) : 5
+ if (n.vacation.maxDays < n.vacation.minDays) {
+ const t = n.vacation.minDays; n.vacation.minDays = n.vacation.maxDays; n.vacation.maxDays = t
+ }
+
+ const cfg: Config = {
+ baseURL: n.baseURL ?? 'https://rewards.bing.com',
+ sessionPath: n.sessionPath ?? 'sessions',
+ headless,
+ parallel,
+ runOnZeroPoints,
+ clusters,
+ saveFingerprint,
+ workers,
+ searchOnBingLocalQueries: !!useLocalQueries,
+ globalTimeout,
+ searchSettings,
+ 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 },
+ webhook,
+ conclusionWebhook,
+ ntfy,
+ diagnostics: n.diagnostics,
+ update: n.update,
+ schedule: n.schedule,
+ passesPerRun: passesPerRun,
+ vacation: n.vacation,
+ buyMode: { enabled: buyModeEnabled, maxMinutes: buyModeMax },
+ crashRecovery: n.crashRecovery || {}
+ }
+
+ return cfg
+}
export function loadAccounts(): Account[] {
try {
+ // 1) CLI dev override
let file = 'accounts.json'
-
- // If dev mode, use dev account(s)
if (process.argv.includes('-dev')) {
file = 'accounts.dev.json'
}
- const accountDir = path.join(__dirname, '../', file)
- const accounts = fs.readFileSync(accountDir, 'utf-8')
+ // 2) Docker-friendly env overrides
+ const envJson = process.env.ACCOUNTS_JSON
+ const envFile = process.env.ACCOUNTS_FILE
- return JSON.parse(accounts)
+ let raw: string | undefined
+ if (envJson && envJson.trim().startsWith('[')) {
+ raw = 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')
+ } else {
+ // Try multiple locations to support both root mounts and dist mounts
+ const candidates = [
+ path.join(__dirname, '../', file), // root/accounts.json (preferred)
+ path.join(__dirname, '../src', file), // fallback: file kept inside src/
+ path.join(process.cwd(), file), // cwd override
+ path.join(process.cwd(), 'src', file), // cwd/src/accounts.json
+ path.join(__dirname, file) // dist/accounts.json (legacy)
+ ]
+ 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')
+ }
+
+ // Support comments in accounts file (same as config)
+ const cleaned = stripJsonComments(raw)
+ const parsedUnknown = JSON.parse(cleaned)
+ // 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')
+ // minimal shape validation
+ for (const a of parsed) {
+ if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
+ throw new Error('each account must have email and password strings')
+ }
+ }
+ return parsed as Account[]
} catch (error) {
throw new Error(error as string)
}
}
+export function getConfigPath(): string { return configSourcePath }
+
export function loadConfig(): Config {
try {
if (configCache) {
return configCache
}
- const configDir = path.join(__dirname, '../', 'config.json')
- const config = fs.readFileSync(configDir, 'utf-8')
+ // Resolve config.json from common locations
+ const candidates = [
+ path.join(__dirname, '../', 'config.json'), // root/config.json when compiled (expected primary)
+ path.join(__dirname, '../src', 'config.json'), // fallback: running compiled dist but file still in src/
+ path.join(process.cwd(), 'config.json'), // cwd root
+ path.join(process.cwd(), 'src', 'config.json'), // running from repo root but config left in src/
+ path.join(__dirname, 'config.json') // last resort: dist/util/config.json
+ ]
+ 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 configData = JSON.parse(config)
- configCache = configData // Set as cache
-
- return configData
+ return normalized
} catch (error) {
throw new Error(error as string)
}
@@ -56,13 +292,19 @@ export async function loadSessionData(sessionPath: string, email: string, isMobi
cookies = JSON.parse(cookiesData)
}
- // Fetch fingerprint file
- const fingerprintFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
+ // Fetch fingerprint file (support both legacy typo "fingerpint" and corrected "fingerprint")
+ const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
+ const legacyFile = path.join(baseDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
+ const correctFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
let fingerprint!: BrowserFingerprintWithHeaders
- if (((saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)) && fs.existsSync(fingerprintFile)) {
- const fingerprintData = await fs.promises.readFile(fingerprintFile, 'utf-8')
- fingerprint = JSON.parse(fingerprintData)
+ const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
+ if (shouldLoad) {
+ const chosen = fs.existsSync(correctFile) ? correctFile : (fs.existsSync(legacyFile) ? legacyFile : '')
+ if (chosen) {
+ const fingerprintData = await fs.promises.readFile(chosen, 'utf-8')
+ fingerprint = JSON.parse(fingerprintData)
+ }
}
return {
@@ -96,7 +338,7 @@ export async function saveSessionData(sessionPath: string, browser: BrowserConte
}
}
-export async function saveFingerprintData(sessionPath: string, email: string, isMobile: boolean, fingerpint: BrowserFingerprintWithHeaders): Promise {
+export async function saveFingerprintData(sessionPath: string, email: string, isMobile: boolean, fingerprint: BrowserFingerprintWithHeaders): Promise {
try {
// Fetch path
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
@@ -106,8 +348,12 @@ export async function saveFingerprintData(sessionPath: string, email: string, is
await fs.promises.mkdir(sessionDir, { recursive: true })
}
- // Save fingerprint to a file
- await fs.promises.writeFile(path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`), JSON.stringify(fingerpint))
+ // 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 ecbbe4f..a9c2138 100644
--- a/src/util/Logger.ts
+++ b/src/util/Logger.ts
@@ -1,45 +1,92 @@
import chalk from 'chalk'
-import { Webhook } from './Webhook'
+import { Ntfy } from './Ntfy'
import { loadConfig } from './Load'
-
-export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): void {
+// Synchronous logger that returns an Error when type === 'error' so callers can `throw log(...)` safely.
+export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void {
const configData = loadConfig()
- if (configData.logExcludeFunc.some(x => x.toLowerCase() === title.toLowerCase())) {
+ // Access logging config with fallback for backward compatibility
+ const configAny = configData as unknown as Record
+ const loggingConfig = configAny.logging || configData
+ const loggingConfigAny = loggingConfig as unknown as Record
+
+ const logExcludeFunc = Array.isArray(loggingConfigAny.excludeFunc) ? loggingConfigAny.excludeFunc :
+ Array.isArray(loggingConfigAny.logExcludeFunc) ? loggingConfigAny.logExcludeFunc : []
+
+ if (Array.isArray(logExcludeFunc) && logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
return
}
const currentTime = new Date().toLocaleString()
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
- const chalkedPlatform = isMobile === 'main' ? chalk.bgCyan('MAIN') : isMobile ? chalk.bgBlue('MOBILE') : chalk.bgMagenta('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||''}`
+ }) : s
+ const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`)
- // Clean string for the Webhook (no chalk)
- const cleanStr = `[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`
-
- // Send the clean string to the Webhook
- if (!configData.webhookLogExcludeFunc.some(x => x.toLowerCase() === title.toLowerCase())) {
- Webhook(configData, cleanStr)
+ // Define conditions for sending to NTFY
+ const ntfyConditions = {
+ log: [
+ message.toLowerCase().includes('started tasks for account'),
+ message.toLowerCase().includes('press the number'),
+ message.toLowerCase().includes('no points to earn')
+ ],
+ error: [],
+ warn: [
+ message.toLowerCase().includes('aborting'),
+ message.toLowerCase().includes('didn\'t gain')
+ ]
}
- // Formatted string with chalk for terminal logging
- const str = `[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${chalkedPlatform} [${title}] ${message}`
+ // Check if the current log type and message meet the NTFY conditions
+ try {
+ if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
+ // Fire-and-forget
+ Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ })
+ }
+ } catch { /* ignore */ }
+
+ // Console output with better formatting
+ 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
+
+ const formattedStr = [
+ chalk.gray(`[${currentTime}]`),
+ chalk.gray(`[${process.pid}]`),
+ typeColor(`${typeIndicator} ${type.toUpperCase()}`),
+ platformColor(`[${platformText}]`),
+ chalk.bold(`[${title}]`),
+ redact(message)
+ ].join(' ')
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
// Log based on the type
switch (type) {
case 'warn':
- applyChalk ? console.warn(applyChalk(str)) : console.warn(str)
+ applyChalk ? console.warn(applyChalk(formattedStr)) : console.warn(formattedStr)
break
case 'error':
- applyChalk ? console.error(applyChalk(str)) : console.error(str)
+ applyChalk ? console.error(applyChalk(formattedStr)) : console.error(formattedStr)
break
default:
- applyChalk ? console.log(applyChalk(str)) : console.log(str)
+ applyChalk ? console.log(applyChalk(formattedStr)) : console.log(formattedStr)
break
}
-}
+
+ // Return an Error when logging an error so callers can `throw log(...)`
+ if (type === 'error') {
+ // CommunityReporter disabled per project policy
+ return new Error(cleanStr)
+ }
+}
\ No newline at end of file
diff --git a/src/util/Ntfy.ts b/src/util/Ntfy.ts
new file mode 100644
index 0000000..b3d447a
--- /dev/null
+++ b/src/util/Ntfy.ts
@@ -0,0 +1,33 @@
+import { loadConfig } from './Load'
+import axios from 'axios'
+
+const NOTIFICATION_TYPES = {
+ error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/
+ warn: { priority: 'high', tags: 'warning' }, // Customize the WARN icon here, see: https://docs.ntfy.sh/emojis/
+ log: { priority: 'default', tags: 'medal_sports' } // Customize the LOG icon here, see: https://docs.ntfy.sh/emojis/
+}
+
+export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPES = 'log'): Promise {
+ const config = loadConfig().ntfy
+ if (!config?.enabled || !config.url || !config.topic) return
+
+ try {
+ const { priority, tags } = NOTIFICATION_TYPES[type]
+ const headers = {
+ Title: 'Microsoft Rewards Script',
+ Priority: priority,
+ Tags: tags,
+ ...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
+ }
+
+ const response = await axios.post(`${config.url}/${config.topic}`, message, { headers })
+
+ if (response.status === 200) {
+ console.log('NTFY notification successfully sent.')
+ } else {
+ console.error(`NTFY notification failed with status ${response.status}`)
+ }
+ } catch (error) {
+ console.error('Failed to send NTFY notification:', error)
+ }
+}
\ No newline at end of file
diff --git a/src/util/Retry.ts b/src/util/Retry.ts
new file mode 100644
index 0000000..af51107
--- /dev/null
+++ b/src/util/Retry.ts
@@ -0,0 +1,63 @@
+import type { ConfigRetryPolicy } from '../interface/Config'
+import Util from './Utils'
+
+type NumericPolicy = {
+ maxAttempts: number
+ baseDelay: number
+ maxDelay: number
+ multiplier: number
+ jitter: number
+}
+
+export type Retryable = () => Promise
+
+export class Retry {
+ private policy: NumericPolicy
+
+ constructor(policy?: ConfigRetryPolicy) {
+ const def: NumericPolicy = {
+ maxAttempts: 3,
+ baseDelay: 1000,
+ maxDelay: 30000,
+ multiplier: 2,
+ jitter: 0.2
+ }
+ const merged: ConfigRetryPolicy = { ...(policy || {}) }
+ // normalize string durations
+ const util = new Util()
+ const parse = (v: number | string) => {
+ if (typeof v === 'number') return v
+ try { return util.stringToMs(String(v)) } catch { return def.baseDelay }
+ }
+ this.policy = {
+ maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts,
+ baseDelay: parse(merged.baseDelay ?? def.baseDelay),
+ maxDelay: parse(merged.maxDelay ?? def.maxDelay),
+ multiplier: (merged.multiplier as number) ?? def.multiplier,
+ jitter: (merged.jitter as number) ?? def.jitter
+ }
+ }
+
+ async run(fn: Retryable, isRetryable?: (e: unknown) => boolean): Promise {
+ let attempt = 0
+ let delay = this.policy.baseDelay
+ let lastErr: unknown
+ while (attempt < this.policy.maxAttempts) {
+ try {
+ return await fn()
+ } catch (e) {
+ lastErr = e
+ attempt += 1
+ const retry = isRetryable ? isRetryable(e) : true
+ if (!retry || attempt >= this.policy.maxAttempts) break
+ const jitter = 1 + (Math.random() * 2 - 1) * this.policy.jitter
+ const sleep = Math.min(this.policy.maxDelay, Math.max(0, Math.floor(delay * jitter)))
+ await new Promise((r) => setTimeout(r, sleep))
+ delay = Math.min(this.policy.maxDelay, Math.floor(delay * (this.policy.multiplier || 2)))
+ }
+ }
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
+ }
+}
+
+export default Retry
diff --git a/src/util/Totp.ts b/src/util/Totp.ts
new file mode 100644
index 0000000..043caaf
--- /dev/null
+++ b/src/util/Totp.ts
@@ -0,0 +1,84 @@
+import crypto from 'crypto'
+
+/**
+ * Decode Base32 (RFC 4648) to a Buffer.
+ * Accepts lowercase/uppercase, optional padding.
+ */
+function base32Decode(input: string): Buffer {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
+ const clean = input.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, '')
+ let bits = 0
+ let value = 0
+ const bytes: number[] = []
+
+ for (const char of clean) {
+ const idx = alphabet.indexOf(char)
+ if (idx < 0) continue
+ value = (value << 5) | idx
+ bits += 5
+ if (bits >= 8) {
+ bits -= 8
+ bytes.push((value >>> bits) & 0xff)
+ }
+ }
+ return Buffer.from(bytes)
+}
+
+/**
+ * Generate an HMAC using Node's crypto and return Buffer.
+ */
+function hmac(algorithm: string, key: Buffer, data: Buffer): Buffer {
+ return crypto.createHmac(algorithm, key).update(data).digest()
+}
+
+export type TotpOptions = { digits?: number; step?: number; algorithm?: 'SHA1' | 'SHA256' | 'SHA512' }
+
+/**
+ * Generate TOTP per RFC 6238.
+ * @param secretBase32 - shared secret in Base32
+ * @param time - Unix time in seconds (defaults to now)
+ * @param options - { digits, step, algorithm }
+ * @returns numeric TOTP as string (zero-padded)
+ */
+export function generateTOTP(
+ secretBase32: string,
+ time: number = Math.floor(Date.now() / 1000),
+ options?: TotpOptions
+): string {
+ const digits = options?.digits ?? 6
+ const step = options?.step ?? 30
+ const alg = (options?.algorithm ?? 'SHA1').toUpperCase()
+
+ const key = base32Decode(secretBase32)
+ const counter = Math.floor(time / step)
+
+ // 8-byte big-endian counter
+ const counterBuffer = Buffer.alloc(8)
+ counterBuffer.writeBigUInt64BE(BigInt(counter), 0)
+
+ let hmacAlg: string
+ if (alg === 'SHA1') hmacAlg = 'sha1'
+ else if (alg === 'SHA256') hmacAlg = 'sha256'
+ else if (alg === 'SHA512') hmacAlg = 'sha512'
+ else throw new Error('Unsupported algorithm. Use SHA1, SHA256 or SHA512.')
+
+ const hash = hmac(hmacAlg, key, counterBuffer)
+ if (!hash || hash.length < 20) {
+ // Minimal sanity check; for SHA1 length is 20
+ throw new Error('Invalid HMAC output for TOTP')
+ }
+
+ // Dynamic truncation
+ const offset = hash[hash.length - 1]! & 0x0f
+ if (offset + 3 >= hash.length) {
+ throw new Error('Invalid dynamic truncation offset')
+ }
+ const code =
+ ((hash[offset]! & 0x7f) << 24) |
+ ((hash[offset + 1]! & 0xff) << 16) |
+ ((hash[offset + 2]! & 0xff) << 8) |
+ (hash[offset + 3]! & 0xff)
+
+ const otp = (code % 10 ** digits).toString().padStart(digits, '0')
+ return otp
+}
diff --git a/src/util/Utils.ts b/src/util/Utils.ts
index aacf661..2ce8d3c 100644
--- a/src/util/Utils.ts
+++ b/src/util/Utils.ts
@@ -8,6 +8,11 @@ export default class Util {
})
}
+ async waitRandom(minMs: number, maxMs: number): Promise {
+ const delta = this.randomNumber(minMs, maxMs)
+ return this.wait(delta)
+ }
+
getFormattedDate(ms = Date.now()): string {
const today = new Date(ms)
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
diff --git a/src/util/Webhook.ts b/src/util/Webhook.ts
deleted file mode 100644
index 050f8f3..0000000
--- a/src/util/Webhook.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import axios from 'axios'
-
-
-import { Config } from '../interface/Config'
-
-export async function Webhook(configData: Config, content: string) {
- const webhook = configData.webhook
-
- if (!webhook.enabled || webhook.url.length < 10) return
-
- const request = {
- method: 'POST',
- url: webhook.url,
- headers: {
- 'Content-Type': 'application/json'
- },
- data: {
- 'content': content
- }
- }
-
- await axios(request).catch(() => { })
-}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 8785759..aba953c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -41,7 +41,6 @@
/* Module Resolution Options */
"moduleResolution":"node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"types": ["node"],
- "typeRoots": ["./node_modules/@types"],
// Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install.
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
@@ -67,8 +66,6 @@
},
"include": [
"src/**/*.ts",
- "src/accounts.json",
- "src/config.json",
"src/functions/queries.json"
],
"exclude": [