diff --git a/Dockerfile b/Dockerfile index 595e837..e42e6ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,8 +80,8 @@ COPY --from=builder /app/node_modules ./node_modules COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh -# Use entrypoint that supports both scheduler and cron +# Use entrypoint that supports single-run and optional cron mode ENTRYPOINT ["docker-entrypoint.sh"] -# Default: use built-in scheduler -CMD ["npm", "run", "start:schedule"] +# Default: single execution +CMD ["node", "--enable-source-maps", "./dist/index.js"] diff --git a/README.md b/README.md index f921228..cfa1b9a 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,11 @@ This TypeScript-based automation bot helps you maximize your **Microsoft Rewards ### ✨ Key Features -- οΏ½ **Automated Searches** β€” Desktop and mobile Bing searches with natural patterns +- πŸ” **Automated Searches** β€” Desktop and mobile Bing searches with natural patterns - πŸ“… **Daily Activities** β€” Quizzes, polls, daily sets, and punch cards - πŸ€– **Human-like Behavior** β€” Advanced humanization system to avoid detection - πŸ›‘οΈ **Risk Management** β€” Built-in ban detection and prediction with ML algorithms -- πŸ“Š **Analytics Dashboard** β€” Track performance and points collection over time -- ⏰ **Smart Scheduling** β€” Built-in scheduler with timezone support +- ⏰ **External Scheduling** β€” Ready for cron, systemd timers, and Windows Task Scheduler - πŸ”” **Notifications** β€” Discord webhooks and NTFY push alerts - 🐳 **Docker Support** β€” Easy containerized deployment - πŸ” **Multi-Account** β€” Manage multiple accounts with parallel execution @@ -89,18 +88,18 @@ For detailed configuration, advanced features, and troubleshooting, visit our co | **[Getting Started](docs/getting-started.md)** | Detailed installation and first-run guide | | **[Configuration](docs/config.md)** | Complete configuration options reference | | **[Accounts & 2FA](docs/accounts.md)** | Setting up accounts with TOTP authentication | -| **[Scheduling](docs/schedule.md)** | Automated daily execution setup | +| **[External Scheduling](docs/schedule.md)** | Use OS schedulers for automation | | **[Docker Deployment](docs/docker.md)** | Running in containers | | **[Humanization](docs/humanization.md)** | Anti-detection and natural behavior | | **[Notifications](docs/conclusionwebhook.md)** | Discord webhooks and NTFY setup | | **[Proxy Setup](docs/proxy.md)** | Configuring proxies for privacy | -| **[Diagnostics](docs/diagnostics.md)** | Troubleshooting and debugging | +| **[Troubleshooting](docs/diagnostics.md)** | Debug common issues and capture logs | --- -## οΏ½ Docker Quick Start +## Docker Quick Start -For containerized deployment with automatic scheduling: +For containerized deployment: ```bash # Ensure accounts.jsonc exists in src/ @@ -124,11 +123,6 @@ The script works great with default settings, but you can customize everything i "enabled": true, // Enable natural behavior patterns "stopOnBan": true // Stop on ban detection }, - "schedule": { - "enabled": true, // Built-in scheduler - "time24": "09:00", // Daily run time - "timeZone": "Europe/Paris" // Your timezone - }, "workers": { "doDesktopSearch": true, // Desktop Bing searches "doMobileSearch": true, // Mobile Bing searches @@ -165,12 +159,12 @@ All while maintaining **natural behavior patterns** to minimize detection risk. ## πŸ’‘ Usage Tips -- **Run regularly:** Set up the built-in scheduler for daily automation +- **Run regularly:** Use cron, systemd timers, or Windows Task Scheduler (see docs) - **Use humanization:** Always keep `humanization.enabled: true` for safety - **Monitor logs:** Check for ban warnings and adjust settings if needed - **Multiple accounts:** Use the `clusters` setting to run accounts in parallel - **Start small:** Test with one account before scaling up -- **Review diagnostics:** Enable screenshot/HTML capture for troubleshooting +- **Capture logs:** Pipe output to a file or webhook for later review --- @@ -185,7 +179,7 @@ All while maintaining **natural behavior patterns** to minimize detection risk. - πŸ’¬ **[Join our Discord](https://discord.gg/k5uHkx9mne)** β€” Community support and updates - πŸ“– **[Documentation Hub](docs/index.md)** β€” Complete guides and references - πŸ› **[Report Issues](https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/issues)** β€” Bug reports and feature requests -- πŸ“§ **[Diagnostics Guide](docs/diagnostics.md)** β€” Troubleshooting steps +- πŸ“§ **[Troubleshooting Guide](docs/diagnostics.md)** β€” Debug common issues --- diff --git a/compose.yaml b/compose.yaml index 6837b46..9191a34 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,25 +11,12 @@ services: - ./sessions:/app/sessions environment: - TZ: "America/Toronto" # Set your timezone for proper scheduling (used by image and scheduler) + TZ: "America/Toronto" # Set your timezone for logging (and cron if enabled) NODE_ENV: "production" # Force headless when running in Docker (uses Chromium Headless Shell only) FORCE_HEADLESS: "1" - # ============================================================ - # SCHEDULING MODE: Choose one - # ============================================================ - # Option 1: Built-in JavaScript Scheduler (default, recommended) - # - No additional setup needed - # - Uses config.jsonc schedule settings - # - Lighter resource usage - #SCHEDULER_DAILY_JITTER_MINUTES_MIN: "2" - #SCHEDULER_DAILY_JITTER_MINUTES_MAX: "10" - #SCHEDULER_PASS_TIMEOUT_MINUTES: "180" - #SCHEDULER_FORK_PER_PASS: "true" - - # Option 2: Native Cron (for users who prefer traditional cron) - # Uncomment these lines to enable cron instead: + # Optional: enable in-container cron scheduling #USE_CRON: "true" #CRON_SCHEDULE: "0 9 * * *" # Daily at 9 AM (see https://crontab.guru) #RUN_ON_START: "true" # Run once immediately on container start @@ -38,5 +25,5 @@ services: security_opt: - no-new-privileges:true - # Default: use built-in scheduler (entrypoint handles mode selection) - command: ["npm", "run", "start:schedule"] \ No newline at end of file + # Default: single run per container start + command: ["node", "--enable-source-maps", "./dist/index.js"] \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index da972d0..ec54e48 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,12 +1,12 @@ #!/bin/bash set -e -# Docker entrypoint with cron support +# Docker entrypoint with optional cron support # Usage: -# Default (scheduler): npm run start:schedule +# Default: node --enable-source-maps ./dist/index.js # Cron mode: set USE_CRON=true -# If USE_CRON is set, configure cron instead of using built-in scheduler +# If USE_CRON is set, configure cron for repeated runs if [ "$USE_CRON" = "true" ] || [ "$USE_CRON" = "1" ]; then echo "==> Cron mode enabled" @@ -57,10 +57,10 @@ if [ "$USE_CRON" = "true" ] || [ "$USE_CRON" = "1" ]; then # Start cron in foreground and tail logs cron && tail -f /var/log/cron.log else - echo "==> Using built-in scheduler (JavaScript)" - echo "==> To use cron instead, set USE_CRON=true" + echo "==> Running single execution" + echo "==> To run on a schedule inside the container, set USE_CRON=true" echo "" - # Execute passed command (default: npm run start:schedule) + # Execute passed command (default: node --enable-source-maps ./dist/index.js) exec "$@" fi diff --git a/docs/FAQ.md b/docs/FAQ.md index 16bde3a..bcf0016 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -80,7 +80,7 @@ npm run setup ### Can I run this on a server 24/7? -Yes! Use Docker with the built-in scheduler for unattended operation. See the [Docker Guide](docker.md). +Yes! Use Docker with your preferred scheduler (cron, Kubernetes CronJob, etc.) or enable the image's optional cron mode. See the [Docker Guide](docker.md). --- @@ -97,27 +97,23 @@ In `src/accounts.jsonc`. Copy `src/accounts.example.jsonc` as a template. Not required, but **highly recommended** for: - Automated login without manual code entry - Better security -- 24/7 scheduler compatibility +- 24/7 automation compatibility See the [Accounts & 2FA Guide](accounts.md). ### How do I schedule automatic runs? -Enable the built-in scheduler in `src/config.jsonc`: +Use your operating system's scheduler. For example, Task Scheduler on Windows or `cron`/systemd timers on Linux: -```jsonc -{ - "schedule": { - "enabled": true, - "time24": "09:00", - "timeZone": "America/New_York" - } -} +```bash +# Windows Task Scheduler action (PowerShell) +powershell.exe -NoProfile -Command "cd 'C:\\Path\\To\\Microsoft-Rewards-Script'; npm run start" + +# Linux cron example (daily at 09:15) +15 9 * * * cd /home/you/Microsoft-Rewards-Script && /usr/bin/env npm run start >> /home/you/rewards.log 2>&1 ``` -Then run: `npm run start:schedule` - -See the [Scheduling Guide](schedule.md). +See the [External Scheduling Guide](schedule.md) for detailed steps. ### Can I run multiple accounts? @@ -312,7 +308,7 @@ See [Configuration Guide](config.md#risk-management--security). - πŸ’¬ **[Join our Discord](https://discord.gg/k5uHkx9mne)** β€” Ask the community - πŸ“– **[Documentation Hub](index.md)** β€” Browse all guides - πŸ› **[GitHub Issues](https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/issues)** β€” Report problems -- πŸ“§ **[Diagnostics Guide](diagnostics.md)** β€” Debug issues +- πŸ“§ **[Troubleshooting Guide](diagnostics.md)** β€” Debug common issues --- diff --git a/docs/accounts.md b/docs/accounts.md index 2b949ee..9d22c7c 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -32,7 +32,7 @@ ### Why Use TOTP? - βœ… **Automated login** β€” No manual code entry - βœ… **More secure** β€” Better than SMS -- βœ… **Works 24/7** β€” Scheduler-friendly +- βœ… **Works 24/7** β€” Ready for external schedulers ### How to Get Your TOTP Secret @@ -178,7 +178,7 @@ export ACCOUNTS_JSON='{"accounts":[{"email":"test@example.com","password":"pass" β†’ **[Security Guide](./security.md)** for best practices **Ready for automation?** -β†’ **[Scheduler Setup](./schedule.md)** +β†’ **[External Scheduling](./schedule.md)** **Need proxies?** β†’ **[Proxy Guide](./proxy.md)** diff --git a/docs/conclusionwebhook.md b/docs/conclusionwebhook.md index 7ca46ae..482a197 100644 --- a/docs/conclusionwebhook.md +++ b/docs/conclusionwebhook.md @@ -115,7 +115,7 @@ curl -X POST -H "Content-Type: application/json" -d '{"content":"Test message"}' β†’ **[NTFY Push Notifications](./ntfy.md)** **Need detailed logs?** -β†’ **[Diagnostics Guide](./diagnostics.md)** +β†’ **[Troubleshooting Guide](./diagnostics.md)** --- diff --git a/docs/config-reference.md b/docs/config-reference.md index 44faa8c..07a2156 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -40,22 +40,6 @@ This page mirrors the defaults that ship in `src/config.jsonc` and explains what --- -## Scheduler - -| Key | Default | Notes | -| --- | --- | --- | -| `schedule.enabled` | `false` | Enable built-in scheduler loop. | -| `schedule.useAmPm` | `false` | Toggle between `time12` (12h) and `time24` (24h). | -| `schedule.time12` | `"9:00 AM"` | Used when `useAmPm` is `true`. | -| `schedule.time24` | `"09:00"` | Used when `useAmPm` is `false`. | -| `schedule.timeZone` | `Europe/Paris` | IANA timezone for scheduling. | -| `schedule.runImmediatelyOnStart` | `true` | Execute one pass right after startup. | -| `schedule.cron` | - | Optional cron expression(s). - -See `docs/schedule.md` for jitter, cron patterns, and vacation integration. - ---- - ## Workers | Key | Default | Notes | @@ -133,13 +117,6 @@ See `docs/schedule.md` for jitter, cron patterns, and vacation integration. | `logging.excludeFunc` | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` | Buckets skipped locally. | | `logging.webhookExcludeFunc` | same | Buckets skipped in webhook payloads. | | `logging.redactEmails` | `true` | Mask email addresses in logs. | -| `diagnostics.enabled` | `true` | Capture screenshots/HTML on failure. | -| `diagnostics.maxPerRun` | `2` | Limit capture count per run. | -| `diagnostics.retentionDays` | `7` | Auto-clean old diagnostics. | -| `analytics.enabled` | `true` | Persist account metrics. | -| `analytics.retentionDays` | `30` | Keep analytics data for N days. | -| `analytics.exportMarkdown` | `true` | Write markdown summaries to `reports/`. | -| `analytics.webhookSummary` | `true` | Send analytics summary via webhook. --- @@ -160,7 +137,7 @@ See `docs/schedule.md` for jitter, cron patterns, and vacation integration. 1. Start from the default config and copy it if you need a local override. 2. Leave `passesPerRun` at `1` so job-state can skip accounts automatically. -3. Enable the scheduler only after testing manual runs. +3. Configure your external scheduler after validating manual runs. 4. Document any changes you make (without storing credentials in git). Related docs: [`accounts.md`](./accounts.md), [`schedule.md`](./schedule.md), [`proxy.md`](./proxy.md), [`humanization.md`](./humanization.md), [`security.md`](./security.md). diff --git a/docs/config.md b/docs/config.md index 98a48ea..4ba2262 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,7 +20,6 @@ This guide explains **how to adjust `src/config.jsonc` safely** and when to touc | Section | Keys to check | Why it matters | | --- | --- | --- | | `execution` | `parallel`, `runOnZeroPoints`, `clusters`, `passesPerRun` | Determines concurrency and whether accounts repeat during the same day. Leave `passesPerRun` at `1` unless you knowingly want additional passes (job-state skip is disabled otherwise). | -| `schedule` | `enabled`, `time12`/`time24`, `timeZone`, `runImmediatelyOnStart` | Controls unattended runs. Test manual runs before enabling the scheduler. | | `workers` | `doDesktopSearch`, `doMobileSearch`, `doDailySet`, etc. | Disable tasks you never want to run to shorten execution time. | | `humanization` | `enabled`, `stopOnBan`, `actionDelay` | Keep enabled for safer automation. Tweaks here influence ban resilience. | | `proxy` | `proxyGoogleTrends`, `proxyBingTerms` | Tell the bot whether to route outbound API calls through your proxy. | @@ -29,19 +28,7 @@ Once these are set, most users can leave the rest alone. --- -## 3. Scheduler & Humanization Coordination - -The scheduler honours humanization constraints: - -- Weekly off-days: controlled by `humanization.randomOffDaysPerWeek` (defaults to 1). The scheduler samples new days each ISO week. -- Allowed windows: if `humanization.allowedWindows` contains time ranges, the bot delays execution until the next window. -- Vacation mode: `vacation.enabled` selects a random contiguous block (between `minDays` and `maxDays`) and skips the entire period. - -If you enable the scheduler (`schedule.enabled: true`), review these limits so the run does not surprise you by skipping on specific days. - ---- - -## 4. Handling Updates Safely +## 3. Handling Updates Safely The `update` block defines how the post-run updater behaves: @@ -53,17 +40,15 @@ When running inside Docker, you can instead rely on `update.docker: true` so the --- -## 5. Diagnostics, Logging, and Analytics +## 4. Logging and Notifications -Three sections determine observability: - -- `logging`: adjust `excludeFunc` and `webhookExcludeFunc` if certain log buckets are too noisy. `redactEmails` should stay `true` in most setups. -- `diagnostics`: captures screenshots/HTML when failures occur. Reduce `maxPerRun` or switch off entirely only if storage is constrained. -- `analytics`: when enabled, daily metrics are persisted under `analytics/` and optional markdown summaries go to `reports//`. Disable if you do not want local history or webhook summaries. +- `logging`: adjust `excludeFunc` and `webhookExcludeFunc` if certain log buckets are too noisy. Keeping `redactEmails: true` prevents leaks when sharing logs. +- `notifications`: use `webhook`, `conclusionWebhook`, or `ntfy` for live updates. All three share the same `{ enabled, url }` structure. +- The validator flags unknown keys automatically, so old sections can be trimmed safely. --- -## 6. Advanced Tips +## 5. Advanced Tips - **Risk management**: Leave `riskManagement.enabled` and `banPrediction` on unless you have a reason to reduce telemetry. Raising `riskThreshold` (>75) makes alerts rarer. - **Search pacing**: The delay window (`search.settings.delay.min` / `max`) accepts either numbers (ms) or strings like `"2min"`. Keep the range wide enough for natural behaviour. @@ -72,15 +57,15 @@ Three sections determine observability: --- -## 7. Validation & Troubleshooting +## 6. Validation & Troubleshooting - The startup validator (`StartupValidator`) emits warnings/errors when config or accounts look suspicious. It never blocks execution but should be read carefully. - For syntax issues, run `npm run typecheck` or open the JSONC file in VS Code to surface parsing errors immediately. -- Diagnostics are written to `reports/` (failures) and `analytics/` (metrics). Clean up periodically or adjust `diagnostics.retentionDays` and `analytics.retentionDays`. +- Keep `logging` focused on the buckets you care about and rely on external log storage if you need long-term retention. --- -## 8. Reference +## 7. Reference For complete field defaults and descriptions, open [`config-reference.md`](./config-reference.md). Additional topic-specific guides: diff --git a/docs/diagnostics.md b/docs/diagnostics.md index fafd182..2a21f8a 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1,103 +1,81 @@ -# πŸ” Diagnostics +# πŸ› οΈ Troubleshooting Guide -**Auto-capture errors with screenshots and HTML** +Keep runs healthy by watching logs, catching alerts early, and validating your setup before enabling automation on a schedule. --- -## πŸ’‘ What Is It? +## Quick Checklist -When errors occur, the script automatically saves: -- πŸ“Έ **Screenshots** β€” Visual error capture -- πŸ“„ **HTML snapshots** β€” Page source - -Helps you debug issues without re-running the script. +- βœ… Run `npm run start` manually after every configuration change. +- βœ… Confirm Node.js 20+ with `node -v` (22 LTS recommended). +- βœ… Keep dependencies current: `npm install` then `npm run build`. +- βœ… Double-check credentials, TOTP secrets, and recovery email values. +- βœ… Review external scheduler logs (Task Scheduler, cron, etc.). --- -## ⚑ Quick Start +## Capture Logs Reliably -**Already enabled by default!** +### Terminal sessions -```jsonc -{ - "diagnostics": { - "enabled": true, - "saveScreenshot": true, - "saveHtml": true, - "maxPerRun": 2, - "retentionDays": 7 - } -} -``` +- **PowerShell** + ```powershell + npm run start *>&1 | Tee-Object -FilePath logs/rewards.txt + ``` +- **Bash / Linux / macOS** + ```bash + mkdir -p logs + npm run start >> logs/rewards.log 2>&1 + ``` ---- +### Verbose output -## πŸ“ Where Are Files Saved? - -``` -reports/ -β”œβ”€β”€ 2025-10-16/ -β”‚ β”œβ”€β”€ error_abc123_001.png -β”‚ β”œβ”€β”€ error_abc123_001.html -β”‚ └── error_def456_002.png -└── 2025-10-17/ - └── ... -``` - -**Auto-cleanup:** Files older than 7 days are deleted automatically. - ---- - -## 🎯 When It Captures - -- ⏱️ **Timeouts** β€” Page navigation failures -- 🎯 **Element not found** β€” Selector errors -- πŸ” **Login failures** β€” Authentication issues -- 🌐 **Network errors** β€” Request failures - ---- - -## πŸ”§ Configuration Options - -| Setting | Default | Description | -|---------|---------|-------------| -| `enabled` | `true` | Enable diagnostics | -| `saveScreenshot` | `true` | Capture PNG screenshots | -| `saveHtml` | `true` | Save page HTML | -| `maxPerRun` | `2` | Max captures per run | -| `retentionDays` | `7` | Auto-delete after N days | - ---- - -## πŸ› οΈ Troubleshooting - -| Problem | Solution | -|---------|----------| -| **No captures despite errors** | Check `enabled: true` | -| **Too many files** | Reduce `retentionDays` | -| **Permission denied** | Check `reports/` write access | - -### Manual Cleanup +Set `DEBUG_REWARDS_VERBOSE=1` for additional context around worker progress and risk scoring. ```powershell -# Delete all diagnostic reports -Remove-Item -Recurse -Force reports/ - -# Keep last 3 days only -Get-ChildItem reports/ | Where-Object {$_.LastWriteTime -lt (Get-Date).AddDays(-3)} | Remove-Item -Recurse +$env:DEBUG_REWARDS_VERBOSE = "1" +npm run start ``` ---- +Clear the variable afterwards (`Remove-Item Env:DEBUG_REWARDS_VERBOSE`). -## πŸ“š Next Steps +### Structured alerts -**Need live notifications?** -β†’ **[Discord Webhooks](./conclusionwebhook.md)** -β†’ **[NTFY Push](./ntfy.md)** - -**Security issues?** -β†’ **[Security Guide](./security.md)** +- Enable `conclusionWebhook` to receive a summary on completion. +- Turn on `ntfy` for lightweight push alerts. +- Pipe logs into observability tools (ELK, Loki, etc.) if you self-host them. --- -**[← Back to Hub](./index.md)** | **[Config Guide](./config.md)** +## Common Issues & Fixes + +| Symptom | Checks | Fix | +|---------|--------|-----| +| **Login loops or MFA prompts** | Ensure `totp` secret is correct, recovery email matches your Microsoft profile. | Regenerate TOTP from Microsoft Account, update `recoveryEmail`, retry manually. | +| **Points not increasing** | Review `workers` section; confirm searches complete in logs. | Enable missing workers, increase `passesPerRun`, verify network connectivity. | +| **Script stops early** | Look for `SECURITY` or `RISK` warnings. | Address ban alerts, adjust `riskManagement` thresholds, or pause for 24h. | +| **Scheduler runs but nothing happens** | Confirm working directory, environment variables, file paths. | Use absolute paths in cron/Task Scheduler, ensure `npm` is available on PATH. | +| **Proxy failures** | Check proxy URL/port/auth in logs. | Test with `curl`/`Invoke-WebRequest`, update credentials, or disable proxy temporarily. | + +--- + +## Manual Investigation Tips + +- **Single account test:** `npm run start -- --account email@example.com` +- **Playwright Inspector:** set `PWDEBUG=1` to pause the browser for step-by-step review. +- **Job state reset:** delete `sessions/job-state/` for a clean pass. +- **Session reset:** remove `sessions/` to force fresh logins. +- **Network tracing:** use the bundled Chromium DevTools (`--devtools`) when running locally. + +--- + +## When to Revisit Config + +- After Microsoft introduces new activities or login flows. +- When risk alerts become frequent (tune delays, enable vacation mode). +- If external schedulers overlap and cause concurrent runs. +- When scaling to more accounts (consider proxies, increase `clusters`). + +--- + +**Related guides:** [Configuration](./config.md) Β· [Notifications](./conclusionwebhook.md) Β· [Security](./security.md) diff --git a/docs/docker.md b/docs/docker.md index b520106..f98bc5d 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,40 +1,33 @@ # 🐳 Docker Guide -**Run the script in a container** +Run the bot in a containerized environment with optional in-container cron support. --- ## ⚑ Quick Start -### 1. Create Required Files +1. **Create required files** + - `src/accounts.jsonc` with your credentials + - `src/config.jsonc` (defaults apply if missing) +2. **Start the container** + ```bash + docker compose up -d + ``` +3. **Watch logs** + ```bash + docker logs -f microsoft-rewards-bot + ``` -Ensure you have: -- `src/accounts.jsonc` with your credentials -- `src/config.jsonc` (uses defaults if missing) - -### 2. Start Container - -```bash -docker compose up -d -``` - -### 3. View Logs - -```bash -docker logs -f microsoft-rewards-bot -``` - -**That's it!** Script runs automatically. +The container performs a single pass. Use cron, Task Scheduler, or another orchestrator to restart it on your desired cadence. --- ## 🎯 What's Included -The Docker setup: -- βœ… **Chromium Headless Shell** β€” Lightweight browser -- βœ… **Scheduler enabled** β€” Daily automation -- βœ… **Volume mounts** β€” Persistent sessions -- βœ… **Force headless** β€” Required for containers +- βœ… Chromium Headless Shell (lightweight browser runtime) +- βœ… Cron-ready entrypoint (`docker-entrypoint.sh`) +- βœ… Volume mounts for persistent sessions and configs +- βœ… Forced headless mode for container stability --- @@ -42,34 +35,30 @@ The Docker setup: | Host Path | Container Path | Purpose | |-----------|----------------|---------| -| `./src/accounts.jsonc` | `/usr/src/.../src/accounts.jsonc` | Account credentials (read-only) | -| `./src/config.jsonc` | `/usr/src/.../src/config.jsonc` | Configuration (read-only) | -| `./sessions` | `/usr/src/.../sessions` | Cookies & fingerprints | +| `./src/accounts.jsonc` | `/app/src/accounts.jsonc` | Account credentials (read-only) | +| `./src/config.jsonc` | `/app/src/config.jsonc` | Configuration (read-only) | +| `./sessions` | `/app/sessions` | Cookies, fingerprints, and job-state | + +Edit `compose.yaml` to adjust paths or add additional mounts. --- ## 🌍 Environment Variables -### Set Timezone - ```yaml services: - rewards: + microsoft-rewards-bot: environment: - TZ: Europe/Paris + TZ: "Europe/Paris" # Container timezone (cron + logging) + NODE_ENV: "production" + FORCE_HEADLESS: "1" # Required for Chromium in Docker + #USE_CRON: "true" # Optional cron mode (see below) + #CRON_SCHEDULE: "0 9 * * *" + #RUN_ON_START: "true" ``` -### Use Inline JSON - -```bash -docker run -e ACCOUNTS_JSON='{"accounts":[...]}' ... -``` - -### Custom Config Path - -```bash -docker run -e ACCOUNTS_FILE=/custom/path/accounts.json ... -``` +- `ACCOUNTS_JSON` and `ACCOUNTS_FILE` can override account sources. +- `ACCOUNTS_JSON` expects inline JSON; `ACCOUNTS_FILE` points to a mounted path. --- @@ -94,17 +83,60 @@ docker compose restart --- +## πŸŽ›οΈ Scheduling Options + +### Use a host scheduler (recommended) + +- Trigger `docker compose up --build` (or restart the container) with cron, systemd timers, Task Scheduler, Kubernetes CronJobs, etc. +- Ensure persistent volumes are mounted so repeated runs reuse state. +- See [External Scheduling](schedule.md) for host-level examples. + +### Enable in-container cron (optional) + +1. Set environment variables in `docker-compose.yml`: + ```yaml + services: + microsoft-rewards-bot: + environment: + USE_CRON: "true" + CRON_SCHEDULE: "0 9,16,21 * * *" # Example: 09:00, 16:00, 21:00 + RUN_ON_START: "true" # Optional one-time run at container boot + ``` +2. Rebuild and redeploy: + ```bash + docker compose down + docker compose build --no-cache + docker compose up -d + ``` +3. Confirm cron is active: + ```bash + docker logs -f microsoft-rewards-bot + ``` + +#### Cron schedule examples + +| Schedule | Description | Cron expression | +|----------|-------------|-----------------| +| Daily at 09:00 | Single run | `0 9 * * *` | +| Twice daily | 09:00 & 21:00 | `0 9,21 * * *` | +| Every 6 hours | Four runs/day | `0 */6 * * *` | +| Weekdays at 08:00 | Monday–Friday | `0 8 * * 1-5` | + +Validate expressions with [crontab.guru](https://crontab.guru). + +--- + ## πŸ› οΈ Troubleshooting | Problem | Solution | |---------|----------| -| **"accounts.json not found"** | Ensure `./src/accounts.jsonc` exists and is mounted in compose.yaml | -| **"Browser launch failed"** | Ensure `FORCE_HEADLESS=1` is set | +| **"accounts.json not found"** | Ensure `./src/accounts.jsonc` exists and is mounted read-only | +| **"Browser launch failed"** | Verify `FORCE_HEADLESS=1` and Chromium dependencies installed | | **"Permission denied"** | Check file permissions (`chmod 644 accounts.jsonc config.jsonc`) | -| **Scheduler not running** | Verify `schedule.enabled: true` in config | -| **Cron not working** | See [Cron Troubleshooting](#-cron-troubleshooting) above | +| **Automation not repeating** | Enable cron (`USE_CRON=true`) or use a host scheduler | +| **Cron not working** | See [Cron troubleshooting](#-cron-troubleshooting) | -### Debug Container +### Debug container ```bash # Enter container shell @@ -113,29 +145,78 @@ docker exec -it microsoft-rewards-bot /bin/bash # Check Node.js version docker exec -it microsoft-rewards-bot node --version -# View config (mounted in /src/) -docker exec -it microsoft-rewards-bot cat src/config.jsonc +# Inspect mounted config +docker exec -it microsoft-rewards-bot cat /app/src/config.jsonc -# Check if cron is enabled -docker exec -it microsoft-rewards-bot printenv | grep USE_CRON +# Check env vars +docker exec -it microsoft-rewards-bot printenv | grep -E "TZ|USE_CRON|CRON_SCHEDULE" ``` --- -## πŸŽ›οΈ Custom Configuration +## πŸ”„ Switching cron on or off -### Option 1: Built-in Scheduler (Default, Recommended) +- **Enable cron:** set `USE_CRON=true`, provide `CRON_SCHEDULE`, rebuild, and redeploy. +- **Disable cron:** remove `USE_CRON` (and related variables). The container will run once per start; handle recurrence externally. -**Pros:** -- βœ… Lighter resource usage -- βœ… Better integration with config.jsonc -- βœ… No additional setup needed -- βœ… Automatic jitter for natural timing +--- -**Default** `docker-compose.yml`: -```yaml -services: - rewards: +## πŸ› Cron troubleshooting + +| Problem | Solution | +|---------|----------| +| **Cron not executing** | Check logs for "Cron mode enabled" and cron syntax errors | +| **Wrong timezone** | Ensure `TZ` matches your location | +| **Syntax error** | Validate expression at [crontab.guru](https://crontab.guru) | +| **No logs generated** | Tail `/var/log/cron.log` inside the container | +| **Duplicate runs** | Ensure only one cron entry is configured | + +### Inspect cron inside the container + +```bash +docker exec -it microsoft-rewards-bot /bin/bash +ps aux | grep cron +crontab -l +tail -100 /var/log/cron.log +``` + +--- + +## πŸ“š Next steps + +- [Configuration guide](config.md) +- [External scheduling](schedule.md) +- [Humanization guide](humanization.md) + +--- + +**[← Back to Hub](index.md)** | **[Getting Started](getting-started.md)**# 🐳 Docker Guide + +**Run the script in a container** + +--- + +## ⚑ Quick Start + +### 1. Create Required Files + +Ensure you have: +- `src/accounts.jsonc` with your credentials +- `src/config.jsonc` (uses defaults if missing) + +### 2. Start Container + +## πŸŽ›οΈ Scheduling Options + +### Use a host scheduler (recommended) + +- Trigger `docker compose up --build` on your preferred schedule (cron, systemd timers, Task Scheduler, Kubernetes CronJob, etc.). +- Ensure volumes remain consistent so each run reuses accounts, config, and sessions. +- See [External Scheduling](schedule.md) for concrete host examples. + +### Enable in-container cron (optional) + +1. Set environment variables in `docker-compose.yml` or `docker run`: build: . environment: TZ: "Europe/Paris" @@ -216,17 +297,10 @@ services: --- -## πŸ”„ Switching Between Scheduler and Cron +## πŸ”„ Switching Cron On or Off -**From Built-in β†’ Cron:** -1. Add `USE_CRON: "true"` to environment -2. Add `CRON_SCHEDULE` with desired timing -3. Rebuild: `docker compose up -d --build` - -**From Cron β†’ Built-in:** -1. Remove or comment `USE_CRON` variable -2. Configure `schedule` in `src/config.jsonc` -3. Rebuild: `docker compose up -d --build` +- **Enable cron:** set `USE_CRON=true`, provide `CRON_SCHEDULE`, rebuild the image, and redeploy. +- **Disable cron:** remove `USE_CRON` (and related variables). The container will run once per start; use host automation to relaunch when needed. --- @@ -269,8 +343,8 @@ printenv | grep -E 'TZ|NODE_ENV' **Want notifications?** β†’ **[Discord Webhooks](./conclusionwebhook.md)** -**Scheduler config?** -β†’ **[Scheduler Guide](./schedule.md)** +**Need scheduling tips?** +β†’ **[External Scheduling](./schedule.md)** --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 767fb35..e9cb9bf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,8 +68,8 @@ npm run build # Single run (test it works) npm start -# Automated daily scheduler (set and forget) -npm run start:schedule +# Schedule it (Task Scheduler, cron, etc.) +# See docs/schedule.md for examples ``` @@ -113,7 +113,7 @@ 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 | +| **High** | **[External Scheduling](./schedule.md)** | Automate with Task Scheduler or cron | | **Medium** | **[Notifications](./ntfy.md)** | Get alerts on your phone | | **Low** | **[Humanization](./humanization.md)** | Advanced anti-detection | @@ -134,5 +134,5 @@ Once running, explore these guides: - **[Accounts & 2FA](./accounts.md)** β€” Add Microsoft accounts with TOTP - **[Docker](./docker.md)** β€” Deploy with containers -- **[Scheduler](./schedule.md)** β€” Automate daily execution +- **[External Scheduling](./schedule.md)** β€” Automate daily execution - **[Discord Webhooks](./conclusionwebhook.md)** β€” Get run summaries diff --git a/docs/humanization.md b/docs/humanization.md index c77e3ea..2b93e02 100644 --- a/docs/humanization.md +++ b/docs/humanization.md @@ -147,10 +147,10 @@ Skip random days per week: ## πŸ“š Next Steps **Need vacation mode?** -β†’ See [Scheduler Vacation](./schedule.md#vacation-mode) +β†’ See [Vacation settings](./config.md#vacation) **Want scheduling?** -β†’ **[Scheduler Guide](./schedule.md)** +β†’ **[External Scheduling](./schedule.md)** **More security?** β†’ **[Security Guide](./security.md)** diff --git a/docs/index.md b/docs/index.md index 157d85e..e69f82b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,18 +14,18 @@ 1. **[Setup Accounts](accounts.md)** β€” Add credentials + 2FA 2. **[Configure Bot](config.md)** β€” Essential settings -3. **[Enable Scheduler](schedule.md)** β€” Daily automation +3. **[Schedule Runs](schedule.md)** β€” Use OS-level automation **Done!** The bot will run automatically. --- -## οΏ½ Feature Guides +## ✨ Feature Guides | Feature | Description | |---------|-------------| | **[Configuration](config.md)** | All settings explained | -| **[Scheduler](schedule.md)** | Automated daily runs | +| **[External Scheduling](schedule.md)** | Automate with cron or Task Scheduler | | **[Humanization](humanization.md)** | Anti-detection system | | **[Webhooks](conclusionwebhook.md)** | Discord notifications | | **[NTFY Alerts](ntfy.md)** | Mobile push notifications | @@ -39,7 +39,7 @@ | Issue | Solution | |-------|----------| -| **Bot not working?** | [Diagnostics Guide](diagnostics.md) | +| **Bot not working?** | [Troubleshooting Guide](diagnostics.md) | | **Login failed?** | [Accounts & 2FA](accounts.md#troubleshooting) | | **Account banned?** | [Security Guide](security.md) | | **Git conflicts?** | [Conflict Resolution](git-conflict-resolution.md) | diff --git a/docs/jobstate.md b/docs/jobstate.md index 0b3f87b..65420e8 100644 --- a/docs/jobstate.md +++ b/docs/jobstate.md @@ -107,11 +107,11 @@ Get-ChildItem sessions/job-state -Recurse -Filter "*.json" | Where-Object {$_.La ## πŸ“š Next Steps -**Need scheduler?** -β†’ **[Scheduler Guide](./schedule.md)** +**Need automation?** +β†’ **[External Scheduling](./schedule.md)** -**Want diagnostics?** -β†’ **[Diagnostics Guide](./diagnostics.md)** +**Need troubleshooting tips?** +β†’ **[Troubleshooting Guide](./diagnostics.md)** --- diff --git a/docs/ntfy.md b/docs/ntfy.md index d6cdc59..e8fc909 100644 --- a/docs/ntfy.md +++ b/docs/ntfy.md @@ -110,8 +110,8 @@ curl -d "Test from rewards script" https://ntfy.sh/your-topic **Want Discord too?** β†’ **[Discord Webhooks](./conclusionwebhook.md)** -**Need detailed logs?** -β†’ **[Diagnostics Guide](./diagnostics.md)** +**Need troubleshooting tips?** +β†’ **[Troubleshooting Guide](./diagnostics.md)** --- diff --git a/docs/proxy.md b/docs/proxy.md index 94c15fe..69f760f 100644 --- a/docs/proxy.md +++ b/docs/proxy.md @@ -113,7 +113,7 @@ curl --proxy http://user:pass@proxy.com:8080 http://httpbin.org/ip ## πŸ“š Next Steps **Proxy working?** -β†’ **[Setup Scheduler](./schedule.md)** +β†’ **[Schedule Runs](./schedule.md)** **Need humanization?** β†’ **[Humanization Guide](./humanization.md)** diff --git a/docs/schedule.md b/docs/schedule.md index 155a1ec..b56836d 100644 Binary files a/docs/schedule.md and b/docs/schedule.md differ diff --git a/docs/security.md b/docs/security.md index 9ab80a3..4427dab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ Your accounts **may be banned**. Use at your own risk. - **Run 1-2x daily max** β€” Don't be greedy - **Test on secondary accounts** β€” Never risk your main account - **Enable vacation mode** β€” Random off days look natural -- **Monitor regularly** β€” Check diagnostics and logs +- **Monitor regularly** β€” Check logs and webhook alerts ### ❌ DON'T @@ -131,20 +131,6 @@ chmod 600 src/accounts.json ## πŸ“Š Monitoring -### Enable Diagnostics - -```jsonc -{ - "diagnostics": { - "enabled": true, - "saveScreenshot": true, - "saveHtml": true - } -} -``` - -β†’ **[Diagnostics Guide](./diagnostics.md)** - ### Enable Notifications ```jsonc @@ -187,7 +173,7 @@ chmod 600 src/accounts.json - 🚫 **No telemetry** β€” Script doesn't phone home - πŸ“ **File security** β€” Restrict permissions - πŸ”„ **Regular backups** β€” Keep config backups -- πŸ—‘οΈ **Clean logs** β€” Delete old diagnostics +- πŸ—‘οΈ **Clean logs** β€” Rotate or delete old log files --- @@ -200,7 +186,7 @@ chmod 600 src/accounts.json β†’ **[Proxy Guide](./proxy.md)** **Want monitoring?** -β†’ **[Diagnostics](./diagnostics.md)** +β†’ **[Notifications Guide](./conclusionwebhook.md)** --- diff --git a/docs/update.md b/docs/update.md index 2c86c69..229c6b1 100644 --- a/docs/update.md +++ b/docs/update.md @@ -96,8 +96,8 @@ npm run build **Need security tips?** β†’ **[Security Guide](./security.md)** -**Setup scheduler?** -β†’ **[Scheduler Guide](./schedule.md)** +**Need automation?** +β†’ **[External Scheduling](./schedule.md)** --- diff --git a/package-lock.json b/package-lock.json index 5e4ca7c..e0741be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "axios": "^1.8.4", "chalk": "^4.1.2", "cheerio": "^1.0.0", - "cron-parser": "^4.9.0", "fingerprint-generator": "^2.1.66", "fingerprint-injector": "^2.1.66", "http-proxy-agent": "^7.0.2", @@ -943,18 +942,6 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 2ef9cae..b5d427c 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,9 @@ "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/update/setup.mjs", + "setup": "node ./setup/update/setup.mjs", "kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"", "create-docker": "docker build -t microsoft-rewards-bot ." }, @@ -63,7 +61,6 @@ "axios": "^1.8.4", "chalk": "^4.1.2", "cheerio": "^1.0.0", - "cron-parser": "^4.9.0", "fingerprint-generator": "^2.1.66", "fingerprint-injector": "^2.1.66", "http-proxy-agent": "^7.0.2", diff --git a/setup/update/update.mjs b/setup/update/update.mjs index 5ced254..15ec758 100644 --- a/setup/update/update.mjs +++ b/setup/update/update.mjs @@ -400,8 +400,7 @@ async function main() { code = await updateDocker() } - // CRITICAL FIX: Always exit with code, even from scheduler - // The scheduler expects the update script to complete and exit + // CRITICAL: Always exit with code so external schedulers can react correctly // Otherwise the process hangs indefinitely and gets killed by watchdog process.exit(code) } diff --git a/src/accounts.example.jsonc b/src/accounts.example.jsonc index 732a0ac..7014e85 100644 --- a/src/accounts.example.jsonc +++ b/src/accounts.example.jsonc @@ -1,12 +1,14 @@ { - // Sample accounts configuration. Copy to accounts.jsonc and fill in real values. + // Sample accounts configuration. Copy to accounts.jsonc and replace with real values. "accounts": [ { + // Account #1 β€” enabled with TOTP and recovery email required "enabled": true, - "email": "email_1@outlook.com", - "password": "password_1", - "totp": "", - "recoveryEmail": "backup_1@example.com", + "email": "primary_account@outlook.com", + "password": "strong-password-1", + "totp": "BASE32SECRETPRIMARY", + "recoveryRequired": true, + "recoveryEmail": "primary.backup@example.com", "proxy": { "proxyAxios": true, "url": "", @@ -16,11 +18,61 @@ } }, { + // Account #2 β€” disabled account kept for later use (recovery optional) "enabled": false, - "email": "email_2@outlook.com", - "password": "password_2", - "totp": "", + "email": "secondary_account@outlook.com", + "password": "strong-password-2", + "totp": "BASE32SECRETSECOND", "recoveryRequired": false, + "recoveryEmail": "secondary.backup@example.com", + "proxy": { + "proxyAxios": true, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + // Account #3 β€” dedicated proxy with credentials + "enabled": true, + "email": "with_proxy@outlook.com", + "password": "strong-password-3", + "totp": "BASE32SECRETTHIRD", + "recoveryRequired": true, + "recoveryEmail": "proxy.backup@example.com", + "proxy": { + "proxyAxios": true, + "url": "proxy.example.com", + "port": 3128, + "username": "proxyuser", + "password": "proxypass" + } + }, + { + // Account #4 β€” recovery optional, no proxying through Axios layer + "enabled": true, + "email": "no_proxy@outlook.com", + "password": "strong-password-4", + "totp": "BASE32SECRETFOUR", + "recoveryRequired": false, + "recoveryEmail": "no.proxy.backup@example.com", + "proxy": { + "proxyAxios": false, + "url": "", + "port": 0, + "username": "", + "password": "" + } + }, + { + // Account #5 β€” enabled with TOTP omitted (will rely on recovery email) + "enabled": true, + "email": "totp_optional@outlook.com", + "password": "strong-password-5", + "totp": "", + "recoveryRequired": true, + "recoveryEmail": "totp.optional.backup@example.com", "proxy": { "proxyAxios": true, "url": "", diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index a12eac0..17f5c27 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -129,7 +129,6 @@ export default class BrowserFunc { if (!scriptContent) { this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn') - await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-missing').catch(() => {}) // Force a navigation retry once before failing hard await this.goHome(target) @@ -148,9 +147,8 @@ export default class BrowserFunc { const dashboardData = await this.parseDashboardFromScript(target, scriptContent) if (!dashboardData) { - await this.bot.browser.utils.captureDiagnostics(target, 'dashboard-data-parse').catch(() => {}) this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error') - throw new Error('Unable to parse dashboard script - check diagnostics') + throw new Error('Unable to parse dashboard script - inspect recent logs and page markup') } return dashboardData diff --git a/src/browser/BrowserUtil.ts b/src/browser/BrowserUtil.ts index 317bccf..3992f4f 100644 --- a/src/browser/BrowserUtil.ts +++ b/src/browser/BrowserUtil.ts @@ -2,7 +2,6 @@ import { Page } from 'rebrowser-playwright' import { load } from 'cheerio' import { MicrosoftRewardsBot } from '../index' -import { captureDiagnostics as captureSharedDiagnostics } from '../util/Diagnostics' type DismissButton = { selector: string; label: string; isXPath?: boolean } @@ -219,12 +218,4 @@ export default class BrowserUtil { } 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 { - await captureSharedDiagnostics(this.bot, page, label) - } - } \ No newline at end of file diff --git a/src/config.jsonc b/src/config.jsonc index 683c7fc..15b6f2b 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -23,14 +23,6 @@ "clusters": 1, "passesPerRun": 1 }, - "schedule": { - "enabled": false, - "useAmPm": false, - "time12": "9:00 AM", - "time24": "09:00", - "timeZone": "Europe/Paris", - "runImmediatelyOnStart": true - }, "jobState": { "enabled": true, "dir": "" @@ -126,7 +118,7 @@ "authToken": "" }, - // Logging & diagnostics + // Logging "logging": { "excludeFunc": [ "SEARCH-CLOSE-TABS", @@ -140,19 +132,6 @@ ], "redactEmails": true }, - "diagnostics": { - "enabled": true, - "saveScreenshot": true, - "saveHtml": true, - "maxPerRun": 2, - "retentionDays": 7 - }, - "analytics": { - "enabled": true, - "retentionDays": 30, - "exportMarkdown": true, - "webhookSummary": true - }, // Buy mode "buyMode": { diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 4d062b0..e8be8ca 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -8,7 +8,6 @@ import { AxiosRequestConfig } from 'axios' import { generateTOTP } from '../util/Totp' import { saveSessionData } from '../util/Load' import { MicrosoftRewardsBot } from '../index' -import { captureDiagnostics } from '../util/Diagnostics' import { OAuth } from '../interface/OAuth' import { Retry } from '../util/Retry' @@ -202,10 +201,7 @@ export class Login { const currentUrl = page.url() this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s (timeout: ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s). Current URL: ${currentUrl}`, 'error') - // Save diagnostics for debugging - await this.saveIncidentArtifacts(page, 'oauth-timeout').catch(() => {}) - - throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s - mobile token acquisition failed. Check diagnostics in reports/`) + throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s - mobile token acquisition failed. Check recent logs for details.`) } this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`) @@ -897,10 +893,8 @@ export class Login { } }).catch(() => ({ title: 'unknown', bodyLength: 0, hasRewardsText: false, visibleElements: 0 })) - this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error') - - await this.bot.browser.utils.captureDiagnostics(page, 'login-portal-missing').catch(()=>{}) - this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing (diagnostics saved)', 'error') + this.bot.log(this.bot.isMobile, 'LOGIN', `Page info: ${JSON.stringify(pageContent)}`, 'error') + this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal element missing', 'error') throw new Error(`Rewards portal not detected. URL: ${currentUrl}. Check reports/ folder`) } this.bot.log(this.bot.isMobile, 'LOGIN', `Portal found via fallback (${fallbackSelector})`) @@ -1092,7 +1086,6 @@ export class Login { 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 @@ -1203,7 +1196,6 @@ export class Login { 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' @@ -1272,10 +1264,6 @@ export class Login { } } - private async saveIncidentArtifacts(page: Page, slug: string) { - await captureDiagnostics(this.bot, page, slug, { scope: 'security', skipSlot: true, force: true }) - } - private async openDocsTab(page: Page, url: string) { try { const ctx = page.context() diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 907d739..ddd5e4b 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -168,7 +168,6 @@ export class Workers { await this.applyThrottle(throttle, 1200, 2600) } 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) } @@ -227,7 +226,6 @@ export class Workers { await runWithTimeout(this.bot.activities.run(page, activity)) throttle.record(true) } catch (e) { - await this.bot.browser.utils.captureDiagnostics(page, `activity_timeout_${activity.title || activity.offerId}`) throttle.record(false) throw e } diff --git a/src/functions/activities/Quiz.ts b/src/functions/activities/Quiz.ts index 4bbe619..4933673 100644 --- a/src/functions/activities/Quiz.ts +++ b/src/functions/activities/Quiz.ts @@ -123,7 +123,6 @@ 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/index.ts b/src/index.ts index e499cc2..670a849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,6 @@ import Humanizer from './util/Humanizer' import { detectBanReason } from './util/BanDetector' import { RiskManager, RiskMetrics, RiskEvent } from './util/RiskManager' import { BanPredictor } from './util/BanPredictor' -import { Analytics } from './util/Analytics' import { QueryDiversityEngine } from './util/QueryDiversityEngine' import JobState from './util/JobState' import { StartupValidator } from './util/StartupValidator' @@ -67,17 +66,12 @@ export class MicrosoftRewardsBot { // 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 private riskManager?: RiskManager private lastRiskMetrics?: RiskMetrics private riskThresholdTriggered: boolean = false private banPredictor?: BanPredictor - private analytics?: Analytics private accountJobState?: JobState private accountRunCounts: Map = new Map() @@ -109,10 +103,6 @@ export class MicrosoftRewardsBot { }) } - if (this.config.analytics?.enabled) { - this.analytics = new Analytics() - } - if (this.config.riskManagement?.enabled) { this.riskManager = new RiskManager() if (this.config.riskManagement.banPrediction) { @@ -190,29 +180,6 @@ export class MicrosoftRewardsBot { return this.lastRiskMetrics?.delayMultiplier ?? 1 } - private trackAnalytics(summary: AccountSummary, riskScore?: number): void { - if (!this.analytics || this.config.analytics?.enabled !== true) return - const today = new Date().toISOString().slice(0, 10) - try { - this.analytics.recordRun({ - date: today, - email: summary.email, - pointsEarned: summary.totalCollected, - pointsInitial: summary.initialTotal, - pointsEnd: summary.endTotal, - desktopPoints: summary.desktopCollected, - mobilePoints: summary.mobileCollected, - executionTimeMs: summary.durationMs, - successRate: summary.errors.length ? 0 : 1, - errorsCount: summary.errors.length, - banned: !!summary.banned?.status, - riskScore - }) - } catch (e) { - log('main', 'ANALYTICS', `Failed to record analytics for ${summary.email}: ${e instanceof Error ? e.message : e}`, 'warn') - } - } - private shouldSkipAccount(email: string, dayKey: string): boolean { if (!this.accountJobState) return false if (this.config.jobState?.skipCompletedAccounts === false) return false @@ -245,20 +212,6 @@ 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}` : '' @@ -446,32 +399,7 @@ export class MicrosoftRewardsBot { console.log(` Auto-Update: ${updTargets.join(', ')}`) } - const sched = this.config.schedule || {} - const schedEnabled = !!sched.enabled - if (!schedEnabled) { - console.log(' Scheduler: Disabled') - } else { - 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) { - formatName = '12h' - timeShown = time12Val || sched.time || '9:00 AM' - } else if (useAmPmVal === false) { - formatName = '24h' - timeShown = time24Val || sched.time || '09:00' - } else { - if (time24Val && time24Val.trim()) { formatName = '24h'; timeShown = time24Val } - else if (time12Val && time12Val.trim()) { formatName = '12h'; timeShown = time12Val } - else { formatName = 'auto'; timeShown = sched.time || '09:00' } - } - console.log(` Scheduler: ${timeShown} (${formatName}, ${tz})`) - } + console.log(' Scheduler: External (see docs)') } console.log('─'.repeat(60) + '\n') } @@ -585,13 +513,8 @@ export class MicrosoftRewardsBot { try { await this.runAutoUpdate() } catch {/* ignore */} - // Only exit if not spawned by scheduler - if (!process.env.SCHEDULER_HEARTBEAT_FILE) { - log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') - process.exit(0) - } else { - log('main', 'MAIN-WORKER', 'All workers destroyed. Scheduler mode: returning control to scheduler.') - } + log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn') + process.exit(0) })() } }) @@ -681,7 +604,6 @@ export class MicrosoftRewardsBot { riskLevel: 'safe' } this.accountSummaries.push(summary) - this.trackAnalytics(summary, summary.riskScore) this.persistAccountCompletion(account.email, accountDayKey, summary) continue } @@ -846,7 +768,6 @@ export class MicrosoftRewardsBot { } this.accountSummaries.push(summary) - this.trackAnalytics(summary, riskScore) this.persistAccountCompletion(account.email, accountDayKey, summary) if (banned.status) { @@ -888,16 +809,10 @@ 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 */}) } - // Only exit if not spawned by scheduler - if (!process.env.SCHEDULER_HEARTBEAT_FILE) { - process.exit() - } + process.exit() } /** Send immediate ban alert if configured. */ @@ -1336,83 +1251,6 @@ export class MicrosoftRewardsBot { log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn') } - // Cleanup old diagnostics - 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') - } - - await this.publishAnalyticsArtifacts().catch(e => { - log('main','ANALYTICS',`Failed analytics post-processing: ${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 */ } - } - } - } - - private async publishAnalyticsArtifacts(): Promise { - if (!this.analytics || this.config.analytics?.enabled !== true) return - - const retention = this.config.analytics.retentionDays - if (typeof retention === 'number' && retention > 0) { - this.analytics.cleanup(retention) - } - - if (this.config.analytics.exportMarkdown || this.config.analytics.webhookSummary) { - const markdown = this.analytics.exportMarkdown(30) - if (this.config.analytics.exportMarkdown) { - 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 mdPath = path.join(baseDir, `analytics_${this.runId}.md`) - fs.writeFileSync(mdPath, markdown, 'utf-8') - log('main','ANALYTICS',`Saved analytics summary to ${mdPath}`) - } - - if (this.config.analytics.webhookSummary) { - const { ConclusionWebhook } = await import('./util/ConclusionWebhook') - await ConclusionWebhook( - this.config, - 'πŸ“ˆ Performance Report', - ['```markdown', markdown, '```'].join('\n'), - undefined, - DISCORD.COLOR_BLUE - ) - } - } } // Run optional auto-update script based on configuration flags. @@ -1501,24 +1339,6 @@ function formatDuration(ms: number): string { } async function main() { - const initialConfig = loadConfig() - const scheduleEnabled = initialConfig?.schedule?.enabled === true - const skipScheduler = process.argv.some((arg: string) => arg === '--no-scheduler' || arg === '--single-run') - || process.env.REWARDS_FORCE_SINGLE_RUN === '1' - const buyModeRequested = process.argv.includes('-buy') - const invokedByScheduler = !!process.env.SCHEDULER_HEARTBEAT_FILE - - if (scheduleEnabled && !skipScheduler && !buyModeRequested && !invokedByScheduler) { - log('main', 'SCHEDULER', 'Schedule enabled β†’ handing off to in-process scheduler. Use --no-scheduler for a single pass.', 'log', 'green') - try { - await import('./scheduler') - return - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - log('main', 'SCHEDULER', `Failed to start scheduler inline: ${message}. Continuing with single-run fallback.`, 'warn', 'yellow') - } - } - const rewardsBot = new MicrosoftRewardsBot(false) const crashState = { restarts: 0 } @@ -1538,7 +1358,6 @@ async function main() { } 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) { diff --git a/src/interface/ActivityHandler.ts b/src/interface/ActivityHandler.ts index 16169f9..215b55f 100644 --- a/src/interface/ActivityHandler.ts +++ b/src/interface/ActivityHandler.ts @@ -7,7 +7,7 @@ import type { Page } from 'playwright' * and perform all required steps on the provided page. */ export interface ActivityHandler { - /** Optional identifier for diagnostics */ + /** Optional identifier used in logging output */ id?: string /** * Return true if this handler knows how to process the given activity. diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 2314894..a07e6d9 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -22,17 +22,15 @@ export interface Config { 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 riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction - analytics?: ConfigAnalytics; // NEW: Performance dashboard and metrics tracking dryRun?: boolean; // NEW: Dry-run mode (simulate without executing) queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation + legacy?: ConfigLegacyFlags; // Track legacy config usage for warnings } export interface ConfigSaveFingerprint { @@ -81,14 +79,6 @@ 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 @@ -102,18 +92,6 @@ export interface ConfigBuyMode { 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 - cron?: string | string[]; // Optional cron expression(s) (standard 5-field or 6-field) for advanced scheduling -} - export interface ConfigVacation { enabled?: boolean; // default false minDays?: number; // default 3 @@ -192,9 +170,9 @@ export interface ConfigLogging { [key: string]: unknown; // forward compatibility } -// CommunityHelp removed (privacy-first policy) +// CommunityHelp intentionally omitted (privacy-first policy) -// NEW FEATURES: Risk Management, Analytics, Query Diversity +// NEW FEATURES: Risk Management and Query Diversity export interface ConfigRiskManagement { enabled?: boolean; // master toggle for risk-aware throttling autoAdjustDelays?: boolean; // automatically increase delays when risk is high @@ -203,13 +181,6 @@ export interface ConfigRiskManagement { riskThreshold?: number; // 0-100, pause if risk exceeds this } -export interface ConfigAnalytics { - enabled?: boolean; // track performance metrics - retentionDays?: number; // how long to keep analytics data - exportMarkdown?: boolean; // generate markdown reports - webhookSummary?: boolean; // send analytics via webhook -} - export interface ConfigQueryDiversity { enabled?: boolean; // use multi-source query generation sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use @@ -217,3 +188,8 @@ export interface ConfigQueryDiversity { cacheMinutes?: number; // cache duration } +export interface ConfigLegacyFlags { + diagnosticsConfigured?: boolean; + analyticsConfigured?: boolean; +} + diff --git a/src/scheduler.ts b/src/scheduler.ts deleted file mode 100644 index 5f72cfa..0000000 --- a/src/scheduler.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { DateTime, IANAZone } from 'luxon' -import cronParser from 'cron-parser' -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' - -type CronExpressionInfo = { expression: string; tz: string } -type DateTimeInstance = ReturnType - -/** - * Parse environment variable as number with validation - */ -function parseEnvNumber(key: string, defaultValue: number, min: number, max: number): number { - const raw = process.env[key] - if (!raw) return defaultValue - - const parsed = Number(raw) - if (isNaN(parsed)) { - void log('main', 'SCHEDULER', `Invalid ${key}="${raw}". Using default ${defaultValue}`, 'warn') - return defaultValue - } - - if (parsed < min || parsed > max) { - void log('main', 'SCHEDULER', `${key}=${parsed} out of range [${min}, ${max}]. Using default ${defaultValue}`, 'warn') - return defaultValue - } - - return parsed -} - -/** - * Parse time from schedule config (supports 12h and 24h formats) - */ -function resolveTimeParts(schedule: Config['schedule'] | undefined): { tz: string; hour: number; minute: number } { - const tz = (schedule?.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : 'UTC' - - // Warn if an invalid timezone was provided - if (schedule?.timeZone && !IANAZone.isValidZone(schedule.timeZone)) { - void log('main', 'SCHEDULER', `Invalid timezone "${schedule.timeZone}" provided. Falling back to UTC. Valid zones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones`, 'warn') - } - - // Determine source string - let src = '' - if (typeof schedule?.useAmPm === 'boolean') { - 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 }) -} - -function normalizeCronExpressions(schedule: Config['schedule'] | undefined, fallbackTz: string): CronExpressionInfo[] { - if (!schedule) return [] - const raw = schedule.cron - if (!raw) return [] - const expressions = Array.isArray(raw) ? raw : [raw] - return expressions - .map(expr => (typeof expr === 'string' ? expr.trim() : '')) - .filter(expr => expr.length > 0) - .map(expr => ({ expression: expr, tz: (schedule.timeZone && IANAZone.isValidZone(schedule.timeZone)) ? schedule.timeZone : fallbackTz })) -} - -function getNextCronOccurrence(after: DateTimeInstance, items: CronExpressionInfo[]): { next: DateTimeInstance; source: string } | null { - let soonest: { next: DateTimeInstance; source: string } | null = null - for (const item of items) { - try { - const iterator = cronParser.parseExpression(item.expression, { - currentDate: after.toJSDate(), - tz: item.tz - }) - const nextDate = iterator.next().toDate() - const nextDt = DateTime.fromJSDate(nextDate, { zone: item.tz }) - if (!soonest || nextDt < soonest.next) { - soonest = { next: nextDt, source: item.expression } - } - } catch (error) { - void log('main', 'SCHEDULER', `Invalid cron expression "${item.expression}": ${error instanceof Error ? error.message : String(error)}`, 'warn') - } - } - return soonest -} - -function getNextDailyOccurrence(after: DateTimeInstance, schedule: Config['schedule'] | undefined): DateTimeInstance { - const todayTarget = parseTargetToday(after.toJSDate(), schedule) - const target = after >= todayTarget ? todayTarget.plus({ days: 1 }) : todayTarget - return target -} - -function computeNextRun(after: DateTimeInstance, schedule: Config['schedule'] | undefined, cronItems: CronExpressionInfo[]): { next: DateTimeInstance; source: 'cron' | 'daily'; detail?: string } { - if (cronItems.length > 0) { - const cronNext = getNextCronOccurrence(after, cronItems) - if (cronNext) { - return { next: cronNext.next, source: 'cron', detail: cronNext.source } - } - void log('main', 'SCHEDULER', 'All cron expressions invalid; falling back to daily schedule', 'warn') - } - - return { next: getNextDailyOccurrence(after, schedule), source: 'daily' } -} - -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 - const staleHeartbeatMin = parseEnvNumber('SCHEDULER_STALE_HEARTBEAT_MINUTES', 30, 5, 1440) - const graceMin = parseEnvNumber('SCHEDULER_HEARTBEAT_GRACE_MINUTES', 15, 1, 120) - const hardcapMin = parseEnvNumber('SCHEDULER_PASS_HARDCAP_MINUTES', 480, 30, 1440) - const checkEveryMs = 60_000 // check once per minute - - // Validate: stale should be >= grace - const effectiveStale = Math.max(staleHeartbeatMin, graceMin) - - // 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() - - let killTimeout: NodeJS.Timeout | undefined - - const killChild = async (signal: NodeJS.Signals) => { - try { - await log('main', 'SCHEDULER', `Sending ${signal} to stuck child PID ${child.pid}`,'warn') - 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') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = 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 >= effectiveStale) { - log('main', 'SCHEDULER', `Heartbeat stale for ${ageMin}m (>=${effectiveStale}m). Terminating child...`, 'warn') - void killChild('SIGTERM') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) - } - } catch (err) { - // If file missing after grace, consider stale - const msg = err instanceof Error ? err.message : String(err) - log('main', 'SCHEDULER', `Heartbeat file check failed: ${msg}. Terminating child...`, 'warn') - void killChild('SIGTERM') - if (killTimeout) clearTimeout(killTimeout) - killTimeout = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, 10_000) - } - }, checkEveryMs) - - child.on('exit', async (code, signal) => { - finished = true - clearInterval(timer) - if (killTimeout) clearTimeout(killTimeout) - // Cleanup heartbeat file - try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ } - if (signal) { - 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) - if (killTimeout) clearTimeout(killTimeout) - try { if (fs.existsSync(hbFile)) fs.unlinkSync(hbFile) } catch { /* ignore */ } - await log('main', 'SCHEDULER', `Failed to spawn child: ${err instanceof Error ? err.message : String(err)}`, 'error') - resolve() - }) - }) -} - -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 - const msg = offDays.length ? offDays.join(', ') : 'none' - await log('main','SCHEDULER',`Weekly humanization off-day sample (ISO weekday): ${msg} | adjust via config.humanization.randomOffDaysPerWeek`,'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' - const cronExpressions = normalizeCronExpressions(schedule, tz) - let running = false - - // Optional initial jitter before the first run (to vary start time) - const parseJitter = (minKey: string, maxKey: string, fallbackMin: string, fallbackMax: string): [number, number] => { - const minVal = Number(process.env[minKey] || process.env[fallbackMin] || 0) - const maxVal = Number(process.env[maxKey] || process.env[fallbackMax] || 0) - if (isNaN(minVal) || minVal < 0) { - void log('main', 'SCHEDULER', `Invalid ${minKey}="${process.env[minKey]}". Using 0`, 'warn') - return [0, isNaN(maxVal) || maxVal < 0 ? 0 : maxVal] - } - if (isNaN(maxVal) || maxVal < 0) { - void log('main', 'SCHEDULER', `Invalid ${maxKey}="${process.env[maxKey]}". Using 0`, 'warn') - return [minVal, 0] - } - return [minVal, maxVal] - } - - const initialJitterBounds = parseJitter('SCHEDULER_INITIAL_JITTER_MINUTES_MIN', 'SCHEDULER_INITIAL_JITTER_MINUTES_MAX', 'SCHEDULER_INITIAL_JITTER_MIN', 'SCHEDULER_INITIAL_JITTER_MAX') - const applyInitialJitter = (initialJitterBounds[0] > 0 || initialJitterBounds[1] > 0) - - // Check if immediate run is enabled (default to false to avoid unexpected runs) - const runImmediate = schedule.runImmediatelyOnStart === true - - if (runImmediate && !running) { - running = true - if (applyInitialJitter) { - const min = Math.max(0, Math.min(initialJitterBounds[0], initialJitterBounds[1])) - const max = Math.max(min, 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: humanization off-day (ISO weekday ${nowDT.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'warn') - } else { - await runPasses(passes) - } - running = false - } - - for (;;) { - const nowDT = DateTime.local().setZone(tz) - const nextInfo = computeNextRun(nowDT, schedule, cronExpressions) - const next = nextInfo.next - let ms = Math.max(0, next.toMillis() - nowDT.toMillis()) - - // Optional daily jitter to further randomize the exact start time each day - let extraMs = 0 - if (cronExpressions.length === 0) { - const dailyJitterBounds = parseJitter('SCHEDULER_DAILY_JITTER_MINUTES_MIN', 'SCHEDULER_DAILY_JITTER_MINUTES_MAX', 'SCHEDULER_DAILY_JITTER_MIN', 'SCHEDULER_DAILY_JITTER_MAX') - const djMin = dailyJitterBounds[0] - const djMax = dailyJitterBounds[1] - if (djMin > 0 || djMax > 0) { - const mn = Math.max(0, Math.min(djMin, djMax)) - const mx = Math.max(mn, 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) - const jitterMsg = extraMs > 0 ? ` plus daily jitter (+${Math.round(extraMs/60000)}m)` : '' - const sourceMsg = nextInfo.source === 'cron' ? ` [cron: ${nextInfo.detail}]` : '' - await log('main', 'SCHEDULER', `Next run at ${human}${jitterMsg}${sourceMsg} (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: humanization off-day (ISO weekday ${nowRun.weekday}). Set humanization.randomOffDaysPerWeek=0 to disable.`,'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) => { - void log('main', 'SCHEDULER', `Fatal error: ${e instanceof Error ? e.message : String(e)}`, 'error') - process.exit(1) -}) diff --git a/src/util/Analytics.ts b/src/util/Analytics.ts index 052e8c7..677da11 100644 --- a/src/util/Analytics.ts +++ b/src/util/Analytics.ts @@ -1,264 +1,3 @@ -import fs from 'fs' -import path from 'path' - -export interface DailyMetrics { - date: string // YYYY-MM-DD - email: string - pointsEarned: number - pointsInitial: number - pointsEnd: number - desktopPoints: number - mobilePoints: number - executionTimeMs: number - successRate: number // 0-1 - errorsCount: number - banned: boolean - riskScore?: number -} - -export interface AccountHistory { - email: string - totalRuns: number - totalPointsEarned: number - avgPointsPerDay: number - avgExecutionTime: number - successRate: number - lastRunDate: string - banHistory: Array<{ date: string; reason: string }> - riskTrend: number[] // last N risk scores -} - -export interface AnalyticsSummary { - period: string // e.g., 'last-7-days', 'last-30-days', 'all-time' - accounts: AccountHistory[] - globalStats: { - totalPoints: number - avgSuccessRate: number - mostProductiveAccount: string - mostRiskyAccount: string - } -} - -/** - * Analytics tracks performance metrics, point collection trends, and account health. - * Stores data in JSON files for lightweight persistence and easy analysis. - */ -export class Analytics { - private dataDir: string - - constructor(baseDir: string = 'analytics') { - this.dataDir = path.join(process.cwd(), baseDir) - if (!fs.existsSync(this.dataDir)) { - fs.mkdirSync(this.dataDir, { recursive: true }) - } - } - - /** - * Record metrics for a completed account run - */ - recordRun(metrics: DailyMetrics): void { - const date = metrics.date - const email = this.sanitizeEmail(metrics.email) - const fileName = `${email}_${date}.json` - const filePath = path.join(this.dataDir, fileName) - - try { - fs.writeFileSync(filePath, JSON.stringify(metrics, null, 2), 'utf-8') - } catch (error) { - console.error(`Failed to save metrics for ${metrics.email}:`, error) - } - } - - /** - * Get history for a specific account - */ - getAccountHistory(email: string, days: number = 30): AccountHistory { - const sanitized = this.sanitizeEmail(email) - const files = this.getAccountFiles(sanitized, days) - - if (files.length === 0) { - return { - email, - totalRuns: 0, - totalPointsEarned: 0, - avgPointsPerDay: 0, - avgExecutionTime: 0, - successRate: 1.0, - lastRunDate: 'never', - banHistory: [], - riskTrend: [] - } - } - - let totalPoints = 0 - let totalTime = 0 - let successCount = 0 - const banHistory: Array<{ date: string; reason: string }> = [] - const riskScores: number[] = [] - - for (const file of files) { - const filePath = path.join(this.dataDir, file) - try { - const data: DailyMetrics = JSON.parse(fs.readFileSync(filePath, 'utf-8')) - totalPoints += data.pointsEarned - totalTime += data.executionTimeMs - if (data.successRate > 0.5) successCount++ - if (data.banned) { - banHistory.push({ date: data.date, reason: 'detected' }) - } - if (typeof data.riskScore === 'number') { - riskScores.push(data.riskScore) - } - } catch { - continue - } - } - - const totalRuns = files.length - const lastFile = files[files.length - 1] - const lastRunDate = lastFile ? lastFile.split('_')[1]?.replace('.json', '') || 'unknown' : 'unknown' - - return { - email, - totalRuns, - totalPointsEarned: totalPoints, - avgPointsPerDay: Math.round(totalPoints / Math.max(1, totalRuns)), - avgExecutionTime: Math.round(totalTime / Math.max(1, totalRuns)), - successRate: successCount / Math.max(1, totalRuns), - lastRunDate, - banHistory, - riskTrend: riskScores.slice(-10) // last 10 risk scores - } - } - - /** - * Generate a summary report for all accounts - */ - generateSummary(days: number = 30): AnalyticsSummary { - const accountEmails = this.getAllAccounts() - const accounts: AccountHistory[] = [] - - for (const email of accountEmails) { - accounts.push(this.getAccountHistory(email, days)) - } - - const totalPoints = accounts.reduce((sum, a) => sum + a.totalPointsEarned, 0) - const avgSuccess = accounts.reduce((sum, a) => sum + a.successRate, 0) / Math.max(1, accounts.length) - - let mostProductive = '' - let maxPoints = 0 - let mostRisky = '' - let maxRisk = 0 - - for (const acc of accounts) { - if (acc.totalPointsEarned > maxPoints) { - maxPoints = acc.totalPointsEarned - mostProductive = acc.email - } - const avgRisk = acc.riskTrend.reduce((s, r) => s + r, 0) / Math.max(1, acc.riskTrend.length) - if (avgRisk > maxRisk) { - maxRisk = avgRisk - mostRisky = acc.email - } - } - - return { - period: `last-${days}-days`, - accounts, - globalStats: { - totalPoints, - avgSuccessRate: Number(avgSuccess.toFixed(2)), - mostProductiveAccount: mostProductive || 'none', - mostRiskyAccount: mostRisky || 'none' - } - } - } - - /** - * Export summary as markdown table (for human readability) - */ - exportMarkdown(days: number = 30): string { - const summary = this.generateSummary(days) - const lines: string[] = [] - - lines.push(`# Analytics Summary (${summary.period})`) - lines.push('') - lines.push('## Global Stats') - lines.push(`- Total Points: ${summary.globalStats.totalPoints}`) - lines.push(`- Avg Success Rate: ${(summary.globalStats.avgSuccessRate * 100).toFixed(1)}%`) - lines.push(`- Most Productive: ${summary.globalStats.mostProductiveAccount}`) - lines.push(`- Most Risky: ${summary.globalStats.mostRiskyAccount}`) - lines.push('') - lines.push('## Per-Account Breakdown') - lines.push('') - lines.push('| Account | Runs | Total Points | Avg/Day | Success Rate | Last Run | Bans |') - lines.push('|---------|------|--------------|---------|--------------|----------|------|') - - for (const acc of summary.accounts) { - const successPct = (acc.successRate * 100).toFixed(0) - const banCount = acc.banHistory.length - lines.push( - `| ${acc.email} | ${acc.totalRuns} | ${acc.totalPointsEarned} | ${acc.avgPointsPerDay} | ${successPct}% | ${acc.lastRunDate} | ${banCount} |` - ) - } - - return lines.join('\n') - } - - /** - * Clean up old analytics files (retention policy) - */ - cleanup(retentionDays: number): void { - const files = fs.readdirSync(this.dataDir) - const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000) - - for (const file of files) { - if (!file.endsWith('.json')) continue - const filePath = path.join(this.dataDir, file) - try { - const stats = fs.statSync(filePath) - if (stats.mtimeMs < cutoff) { - fs.unlinkSync(filePath) - } - } catch { - continue - } - } - } - - private sanitizeEmail(email: string): string { - return email.replace(/[^a-zA-Z0-9@._-]/g, '_') - } - - private getAccountFiles(sanitizedEmail: string, days: number): string[] { - const files = fs.readdirSync(this.dataDir) - const cutoffDate = new Date() - cutoffDate.setDate(cutoffDate.getDate() - days) - - return files - .filter((f: string) => f.startsWith(sanitizedEmail) && f.endsWith('.json')) - .filter((f: string) => { - const datePart = f.split('_')[1]?.replace('.json', '') - if (!datePart) return false - const fileDate = new Date(datePart) - return fileDate >= cutoffDate - }) - .sort() - } - - private getAllAccounts(): string[] { - const files = fs.readdirSync(this.dataDir) - const emailSet = new Set() - - for (const file of files) { - if (!file.endsWith('.json')) continue - const parts = file.split('_') - if (parts.length >= 2) { - const email = parts[0] - if (email) emailSet.add(email) - } - } - - return Array.from(emailSet) - } -} +// Placeholder kept for backward compatibility with older imports. +// New code should implement its own reporting or use webhooks. +export {} diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts index 9febc47..2f77bbf 100644 --- a/src/util/ConfigValidator.ts +++ b/src/util/ConfigValidator.ts @@ -197,35 +197,32 @@ export class ConfigValidator { } } - // Check schedule - if (config.schedule?.enabled) { - if (!config.schedule.timeZone) { - issues.push({ - severity: 'warning', - field: 'schedule.timeZone', - message: 'No timeZone specified, defaulting to UTC', - suggestion: 'Set your local timezone (e.g., America/New_York)' - }) - } + const legacySchedule = (config as unknown as { schedule?: unknown }).schedule + if (legacySchedule !== undefined) { + issues.push({ + severity: 'warning', + field: 'schedule', + message: 'Legacy schedule block detected.', + suggestion: 'Remove schedule.* entries and configure OS-level scheduling (docs/schedule.md).' + }) + } - const useAmPm = config.schedule.useAmPm - const time12 = (config.schedule as unknown as Record)['time12'] - const time24 = (config.schedule as unknown as Record)['time24'] + if (config.legacy?.diagnosticsConfigured) { + issues.push({ + severity: 'warning', + field: 'diagnostics', + message: 'Unrecognized diagnostics.* block in config.jsonc.', + suggestion: 'Delete the diagnostics section; logs and webhooks now cover troubleshooting.' + }) + } - if (useAmPm === true && (!time12 || (typeof time12 === 'string' && time12.trim() === ''))) { - issues.push({ - severity: 'error', - field: 'schedule.time12', - message: 'useAmPm is true but time12 is empty' - }) - } - if (useAmPm === false && (!time24 || (typeof time24 === 'string' && time24.trim() === ''))) { - issues.push({ - severity: 'error', - field: 'schedule.time24', - message: 'useAmPm is false but time24 is empty' - }) - } + if (config.legacy?.analyticsConfigured) { + issues.push({ + severity: 'warning', + field: 'analytics', + message: 'Unrecognized analytics.* block in config.jsonc.', + suggestion: 'Delete the analytics section because those values are ignored.' + }) } // Check workers @@ -248,27 +245,6 @@ export class ConfigValidator { } } - // Check diagnostics - if (config.diagnostics?.enabled) { - const maxPerRun = config.diagnostics.maxPerRun || 2 - if (maxPerRun > 20) { - issues.push({ - severity: 'warning', - field: 'diagnostics.maxPerRun', - message: 'Very high maxPerRun may fill disk quickly' - }) - } - - const retention = config.diagnostics.retentionDays || 7 - if (retention > 90) { - issues.push({ - severity: 'info', - field: 'diagnostics.retentionDays', - message: 'Long retention period - monitor disk usage' - }) - } - } - const valid = !issues.some(i => i.severity === 'error') return { valid, issues } } diff --git a/src/util/Diagnostics.ts b/src/util/Diagnostics.ts index 5a44f1f..dd843a8 100644 --- a/src/util/Diagnostics.ts +++ b/src/util/Diagnostics.ts @@ -1,74 +1,3 @@ -import path from 'path' -import fs from 'fs' -import type { Page } from 'rebrowser-playwright' -import type { MicrosoftRewardsBot } from '../index' - -export type DiagnosticsScope = 'default' | 'security' - -export interface DiagnosticsOptions { - scope?: DiagnosticsScope - skipSlot?: boolean - force?: boolean -} - -export async function captureDiagnostics(bot: MicrosoftRewardsBot, page: Page, rawLabel: string, options?: DiagnosticsOptions): Promise { - try { - const scope: DiagnosticsScope = options?.scope ?? 'default' - const cfg = bot.config?.diagnostics ?? {} - const forceCapture = options?.force === true || scope === 'security' - if (!forceCapture && cfg.enabled === false) return - - if (scope === 'default') { - const maxPerRun = typeof cfg.maxPerRun === 'number' ? cfg.maxPerRun : 8 - if (!options?.skipSlot && !bot.tryReserveDiagSlot(maxPerRun)) return - } - - const saveScreenshot = scope === 'security' ? true : cfg.saveScreenshot !== false - const saveHtml = scope === 'security' ? true : cfg.saveHtml !== false - if (!saveScreenshot && !saveHtml) return - - const safeLabel = rawLabel.replace(/[^a-z0-9-_]/gi, '_').slice(0, 64) || 'capture' - const now = new Date() - const timestamp = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}` - - let dir: string - if (scope === 'security') { - const base = path.join(process.cwd(), 'diagnostics', 'security-incidents') - fs.mkdirSync(base, { recursive: true }) - const sub = `${now.toISOString().replace(/[:.]/g, '-')}-${safeLabel}` - dir = path.join(base, sub) - fs.mkdirSync(dir, { recursive: true }) - } else { - const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` - dir = path.join(process.cwd(), 'reports', day) - fs.mkdirSync(dir, { recursive: true }) - } - - if (saveScreenshot) { - const shotName = scope === 'security' ? 'page.png' : `${timestamp}_${safeLabel}.png` - const shotPath = path.join(dir, shotName) - await page.screenshot({ path: shotPath }).catch(() => {}) - if (scope === 'security') { - bot.log(bot.isMobile, 'DIAG', `Saved security screenshot to ${shotPath}`) - } else { - bot.log(bot.isMobile, 'DIAG', `Saved diagnostics screenshot to ${shotPath}`) - } - } - - if (saveHtml) { - const htmlName = scope === 'security' ? 'page.html' : `${timestamp}_${safeLabel}.html` - const htmlPath = path.join(dir, htmlName) - try { - const html = await page.content() - await fs.promises.writeFile(htmlPath, html, 'utf-8') - if (scope === 'security') { - bot.log(bot.isMobile, 'DIAG', `Saved security HTML to ${htmlPath}`) - } - } catch { - /* ignore */ - } - } - } catch (error) { - bot.log(bot.isMobile, 'DIAG', `Failed to capture diagnostics: ${error instanceof Error ? error.message : error}`, 'warn') - } -} +// Placeholder kept for backward compatibility with older imports. +// New code should handle troubleshooting through logging and webhooks instead. +export {} diff --git a/src/util/Load.ts b/src/util/Load.ts index c3e8e90..9079f36 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -5,7 +5,7 @@ import path from 'path' import { Account } from '../interface/Account' -import { Config, ConfigSaveFingerprint } from '../interface/Config' +import { Config, ConfigLegacyFlags, ConfigSaveFingerprint } from '../interface/Config' let configCache: Config let configSourcePath = '' @@ -168,15 +168,6 @@ function normalizeConfig(raw: unknown): Config { riskThreshold: typeof riskRaw.riskThreshold === 'number' ? riskRaw.riskThreshold : undefined } : undefined - const analyticsRaw = (n.analytics ?? {}) as Record - const hasAnalyticsCfg = Object.keys(analyticsRaw).length > 0 - const analytics = hasAnalyticsCfg ? { - enabled: analyticsRaw.enabled === true, - retentionDays: typeof analyticsRaw.retentionDays === 'number' ? analyticsRaw.retentionDays : undefined, - exportMarkdown: analyticsRaw.exportMarkdown === true, - webhookSummary: analyticsRaw.webhookSummary === true - } : undefined - const queryDiversityRaw = (n.queryDiversity ?? {}) as Record const hasQueryCfg = Object.keys(queryDiversityRaw).length > 0 const queryDiversity = hasQueryCfg ? { @@ -197,6 +188,15 @@ function normalizeConfig(raw: unknown): Config { skipCompletedAccounts: jobStateRaw.skipCompletedAccounts !== false } + const legacy: ConfigLegacyFlags = {} + if (typeof n.diagnostics !== 'undefined') { + legacy.diagnosticsConfigured = true + } + if (typeof n.analytics !== 'undefined') { + legacy.analyticsConfigured = true + } + const hasLegacyFlags = legacy.diagnosticsConfigured === true || legacy.analyticsConfigured === true + const cfg: Config = { baseURL: n.baseURL ?? 'https://rewards.bing.com', sessionPath: n.sessionPath ?? 'sessions', @@ -219,17 +219,15 @@ function normalizeConfig(raw: unknown): Config { 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 || {}, riskManagement, - analytics, dryRun, - queryDiversity + queryDiversity, + legacy: hasLegacyFlags ? legacy : undefined } return cfg diff --git a/src/util/StartupValidator.ts b/src/util/StartupValidator.ts index 2bbf45a..16077a7 100644 --- a/src/util/StartupValidator.ts +++ b/src/util/StartupValidator.ts @@ -32,7 +32,6 @@ export class StartupValidator { this.validateEnvironment() this.validateFileSystem(config) this.validateBrowserSettings(config) - this.validateScheduleSettings(config) this.validateNetworkSettings(config) this.validateWorkerSettings(config) this.validateSearchSettings(config) @@ -173,6 +172,16 @@ export class StartupValidator { } private validateConfig(config: Config): void { + const maybeSchedule = (config as unknown as { schedule?: unknown }).schedule + if (maybeSchedule !== undefined) { + this.addWarning( + 'config', + 'Legacy schedule settings detected in config.jsonc.', + 'Remove schedule.* entries and use your operating system scheduler.', + 'docs/schedule.md' + ) + } + // Headless mode in Docker if (process.env.FORCE_HEADLESS === '1' && config.headless === false) { this.addWarning( @@ -330,20 +339,13 @@ export class StartupValidator { } } - // Check diagnostics directory if enabled - if (config.diagnostics?.enabled === true) { - const diagPath = path.join(process.cwd(), 'diagnostics') - if (!fs.existsSync(diagPath)) { - try { - fs.mkdirSync(diagPath, { recursive: true }) - } catch (error) { - this.addWarning( - 'filesystem', - 'Cannot create diagnostics directory', - 'Screenshots and HTML snapshots will not be saved' - ) - } - } + if (config.legacy?.diagnosticsConfigured || config.legacy?.analyticsConfigured) { + this.addWarning( + 'filesystem', + 'Unrecognized diagnostics/analytics block detected in config.jsonc', + 'Remove those sections to keep the file aligned with the current schema.', + 'docs/diagnostics.md' + ) } } @@ -368,60 +370,6 @@ export class StartupValidator { } } - private validateScheduleSettings(config: Config): void { - if (config.schedule?.enabled === true) { - // Time format validation - const schedRec = config.schedule as Record - const useAmPm = schedRec.useAmPm - const time12 = typeof schedRec.time12 === 'string' ? schedRec.time12 : '' - const time24 = typeof schedRec.time24 === 'string' ? schedRec.time24 : '' - - if (useAmPm === true && (!time12 || time12.trim() === '')) { - this.addError( - 'schedule', - 'Schedule enabled with useAmPm=true but time12 is missing', - 'Add time12 field (e.g., "9:00 AM") or set useAmPm=false', - 'docs/schedule.md' - ) - } - - if (useAmPm === false && (!time24 || time24.trim() === '')) { - this.addError( - 'schedule', - 'Schedule enabled with useAmPm=false but time24 is missing', - 'Add time24 field (e.g., "09:00") or set useAmPm=true', - 'docs/schedule.md' - ) - } - - // Timezone validation - const tz = config.schedule.timeZone || 'UTC' - try { - Intl.DateTimeFormat(undefined, { timeZone: tz }) - } catch { - this.addError( - 'schedule', - `Invalid timezone: ${tz}`, - 'Use a valid IANA timezone (e.g., "America/New_York", "Europe/Paris")', - 'docs/schedule.md' - ) - } - - // Vacation mode check - if (config.vacation?.enabled === true) { - if (config.vacation.minDays && config.vacation.maxDays) { - if (config.vacation.minDays > config.vacation.maxDays) { - this.addError( - 'schedule', - `Vacation minDays (${config.vacation.minDays}) > maxDays (${config.vacation.maxDays})`, - 'Set minDays <= maxDays (e.g., minDays: 2, maxDays: 4)' - ) - } - } - } - } - } - private validateNetworkSettings(config: Config): void { // Webhook validation if (config.webhook?.enabled === true) { @@ -651,8 +599,6 @@ export class StartupValidator { ) } - // Removed diagnostics warning - reports/ folder with masked emails is safe for debugging - // Proxy exposure check if (config.proxy?.proxyGoogleTrends === false && config.proxy?.proxyBingTerms === false) { this.addWarning(