refactor: remove legacy scheduling and analytics code

- Deleted the scheduler module and its associated functions, transitioning to OS-level scheduling.
- Removed the Analytics module and its related interfaces, retaining only a placeholder for backward compatibility.
- Updated ConfigValidator to warn about legacy schedule and analytics configurations.
- Cleaned up StartupValidator to remove diagnostics and schedule validation logic.
- Adjusted Load.ts to handle legacy flags for diagnostics and analytics.
- Removed unused diagnostics capturing functionality.
This commit is contained in:
2025-11-03 19:18:09 +01:00
parent 67006d7e93
commit 43ed6cd7f8
39 changed files with 415 additions and 1494 deletions

View File

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

View File

@@ -21,12 +21,11 @@ This TypeScript-based automation bot helps you maximize your **Microsoft Rewards
### ✨ Key Features
- <EFBFBD> **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 |
---
## <20> 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
---

View File

@@ -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"]
# Default: single run per container start
command: ["node", "--enable-source-maps", "./dist/index.js"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<date>/`. 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:

View File

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

View File

@@ -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 | MondayFriday | `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)**
---

View File

@@ -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
```
</details>
@@ -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

View File

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

View File

@@ -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.
---
## <EFBFBD> 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) |

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

13
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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": "",

View File

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

View File

@@ -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/<date>/ with a safe label.
*/
async captureDiagnostics(page: Page, label: string): Promise<void> {
await captureSharedDiagnostics(this.bot, page, label)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, number> = 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<string, unknown> = sched as unknown as Record<string, unknown>
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<void> {
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) {

View File

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

View File

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

View File

@@ -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<typeof DateTime.fromJSDate>
/**
* 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<void> {
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<void> {
// 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<void>((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<void> {
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;i<Math.min(offPerWeek,7);i++) {
const idx = Math.floor(Math.random()*pool.length)
chosen.push(pool[idx]!)
pool.splice(idx,1)
}
offDays = chosen.sort((a,b)=>a-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)
})

View File

@@ -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<string>()
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 {}

View File

@@ -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<string, unknown>)['time12']
const time24 = (config.schedule as unknown as Record<string, unknown>)['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 }
}

View File

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

View File

@@ -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<string, unknown>
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<string, unknown>
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

View File

@@ -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<string, unknown>
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(