diff --git a/README.md b/README.md index c2d3822..fd972db 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,36 @@ Access at `http://localhost:3000` to: --- +## ⏰ Automatic Scheduling + +Configure automatic task scheduling directly from `config.jsonc` - **perfect for Raspberry Pi!** + +```jsonc +{ + "scheduling": { + "enabled": true, // Just set this to true + "type": "auto", // Automatically detects Windows/Linux/Raspberry Pi + "cron": { + "schedule": "0 9 * * *" // Raspberry Pi/Linux: Daily at 9 AM + }, + "taskScheduler": { + "schedule": "09:00" // Windows: Daily at 9:00 + } + } +} +``` + +**Then simply run:** +```bash +npm run start +``` + +The bot will automatically configure cron (Linux/Raspberry Pi) or Task Scheduler (Windows) for you! + +**[📖 Full Scheduling Documentation](docs/schedule.md)** + +--- + ## Docker Quick Start For containerized deployment: diff --git a/docs/schedule.md b/docs/schedule.md index 771bc8b..c5870a3 100644 --- a/docs/schedule.md +++ b/docs/schedule.md @@ -1,10 +1,155 @@ -# External Scheduling +# Automatic Task Scheduling -The built-in scheduler has been removed. Use your operating system or orchestrator to trigger runs with full control over timing, retries, and monitoring. +The bot can **automatically configure** your system's task scheduler when you run it for the first time. This works on: +- ✅ **Windows** → Windows Task Scheduler +- ✅ **Linux/Raspberry Pi** → cron +- ✅ **macOS** → cron --- -## Windows Task Scheduler +## Quick Setup (Recommended) + +### 1. Edit your configuration + +Open `src/config.jsonc` and find the `scheduling` section: + +```jsonc +{ + "scheduling": { + "enabled": true, // ← Change this to true + "type": "auto", // ← Leave as "auto" for automatic detection + + // For Linux/Raspberry Pi/macOS: + "cron": { + "schedule": "0 9 * * *" // ← Daily at 9 AM (customize if needed) + }, + + // For Windows: + "taskScheduler": { + "schedule": "09:00", // ← Daily at 9:00 AM (customize if needed) + "frequency": "daily" + } + } +} +``` + +### 2. Run the bot once + +```bash +npm run start +``` + +**That's it!** The bot will automatically: +- Detect your operating system +- Configure the appropriate scheduler (cron or Task Scheduler) +- Set it up to run at your specified time +- Show you a confirmation message + +### 3. Verify it worked + +**Linux/Raspberry Pi/macOS:** +```bash +crontab -l +``` +You should see a line with `# Microsoft-Rewards-Bot` + +**Windows:** +- Open Task Scheduler +- Look for "Microsoft-Rewards-Bot" in the task list + +--- + +## Configuration Examples + +### Example 1: Raspberry Pi - Run daily at 9 AM + +```jsonc +{ + "scheduling": { + "enabled": true, + "type": "auto", + "cron": { + "schedule": "0 9 * * *", + "logFile": "/home/pi/rewards.log" // Optional: save logs here + } + } +} +``` + +### Example 2: Windows - Run twice daily + +```jsonc +{ + "scheduling": { + "enabled": true, + "type": "auto", + "taskScheduler": { + "schedule": "09:00", // First run at 9 AM + "frequency": "daily" + } + } +} +``` + +For multiple times per day on Windows, you'll need to manually create additional tasks. + +### Example 3: Linux - Run on weekdays only at 2:30 PM + +```jsonc +{ + "scheduling": { + "enabled": true, + "type": "cron", + "cron": { + "schedule": "30 14 * * 1-5" // 2:30 PM, Monday-Friday + } + } +} +``` + +### Cron Schedule Examples + +Use [crontab.guru](https://crontab.guru) to create custom schedules: + +| Schedule | Description | +|----------|-------------| +| `0 9 * * *` | Every day at 9:00 AM | +| `30 14 * * *` | Every day at 2:30 PM | +| `0 9,21 * * *` | Every day at 9:00 AM and 9:00 PM | +| `0 9 * * 1-5` | Weekdays at 9:00 AM (Monday-Friday) | +| `0 */6 * * *` | Every 6 hours | +| `0 8 * * 0` | Every Sunday at 8:00 AM | + +--- + +## Disabling Automatic Scheduling + +To remove the scheduled task: + +1. Set `"enabled": false` in your config +2. Run the bot once: `npm run start` +3. The scheduler will be automatically removed + +Or manually remove it: + +**Linux/Raspberry Pi/macOS:** +```bash +crontab -e +# Delete the line with "# Microsoft-Rewards-Bot" +``` + +**Windows:** +- Open Task Scheduler +- Find "Microsoft-Rewards-Bot" +- Right-click → Delete + +--- + +## Manual Configuration (Advanced) + +If you prefer manual setup or need more control, follow these platform-specific guides: + +### Windows Task Scheduler (Manual) 1. Open Task Scheduler, choose **Create Basic Task...**, and name it `Microsoft Rewards Bot`. 2. Pick a trigger (daily, weekly, at startup, etc.). @@ -19,7 +164,7 @@ The built-in scheduler has been removed. Use your operating system or orchestrat --- -## Linux / macOS (cron) +## Linux / macOS (cron - Manual) 1. Run `npm run start` once to confirm the project completes successfully. 2. Edit the crontab: `crontab -e`. @@ -33,7 +178,7 @@ Need multiple runs? Add more cron lines with different times (for example `0 9 * --- -## Systemd Timer (Linux alternative) +## Systemd Timer (Linux alternative - Manual) 1. Create `/etc/systemd/system/rewards-bot.service`: ```ini @@ -83,10 +228,38 @@ Need multiple runs? Add more cron lines with different times (for example `0 9 * ## Troubleshooting -- Run `npm install` and `npm run build` after pulling updates. -- Use absolute paths in scheduler commands. -- Redirect output to a log file for easier debugging. -- Execute `npm run start` manually after configuration changes to trigger the startup validator. +**"cron is not installed"** (Linux/Raspberry Pi) +```bash +sudo apt-get update +sudo apt-get install cron +``` + +**"Permission denied"** (Linux/Raspberry Pi) +- The bot needs write access to crontab +- Make sure you're running as the correct user + +**"Access denied"** (Windows) +- Right-click PowerShell or Command Prompt +- Choose "Run as Administrator" +- Run `npm run start` again + +**Task not running at scheduled time:** +1. Check your system's time and timezone +2. Verify the schedule format is correct +3. For cron: use [crontab.guru](https://crontab.guru) to validate +4. Check logs to see if there are any errors + +**Manually check if scheduler is active:** + +**Linux/Raspberry Pi:** +```bash +crontab -l | grep "Microsoft-Rewards" +``` + +**Windows:** +```powershell +schtasks /Query /TN "Microsoft-Rewards-Bot" /FO LIST +``` --- diff --git a/src/config.jsonc b/src/config.jsonc index b4eef5c..ed3f59f 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -154,7 +154,42 @@ "scriptPath": "setup/update/update.mjs", "autoUpdateConfig": true, "autoUpdateAccounts": false + }, + + // Scheduling (automatic task scheduling) + // When enabled=true, the bot will automatically configure your system scheduler on first run. + // This works on Windows (Task Scheduler), Linux/Raspberry Pi (cron), and macOS (cron). + "scheduling": { + "enabled": false, // Set to true to enable automatic scheduling + + // Leave "type" as "auto" for automatic detection, or force "cron" (Linux/Raspberry Pi/macOS) or "task-scheduler" (Windows) + "type": "auto", + + // Cron settings (for Linux, Raspberry Pi, macOS) + // Only used when type="auto" on Linux/macOS or type="cron" + "cron": { + "schedule": "0 9 * * *", // When to run: 9 AM daily (see https://crontab.guru to customize) + // Examples: + // "0 9 * * *" = Every day at 9:00 AM + // "30 14 * * *" = Every day at 2:30 PM + // "0 9,21 * * *" = Every day at 9:00 AM and 9:00 PM + // "0 9 * * 1-5" = Weekdays at 9:00 AM (Monday-Friday) + + "workingDirectory": "", // Leave empty for auto-detection + "nodePath": "", // Leave empty for auto-detection + "logFile": "", // Optional: custom log file path (e.g., "/home/pi/rewards.log") + "user": "" // Optional: run as specific user (leave empty for current user) + }, + + // Windows Task Scheduler settings + // Only used when type="auto" on Windows or type="task-scheduler" + "taskScheduler": { + "taskName": "Microsoft-Rewards-Bot", // Task name in Windows Task Scheduler + "schedule": "09:00", // Time in 24h format (e.g., "09:00", "14:30", "21:00") + "frequency": "daily", // "daily", "weekly", or "once" + "workingDirectory": "", // Leave empty for auto-detection + "runAsUser": true, // true = run as current user, false = run as SYSTEM + "highestPrivileges": false // Set to true if you need admin privileges + } } } - - diff --git a/src/index.ts b/src/index.ts index 90fa1d1..6210946 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { QueryDiversityEngine } from './util/QueryDiversityEngine' import JobState from './util/JobState' import { StartupValidator } from './util/StartupValidator' import { MobileRetryTracker } from './util/MobileRetryTracker' +import { SchedulerManager } from './util/SchedulerManager' import { Login } from './functions/Login' import { Workers } from './functions/Workers' @@ -128,6 +129,12 @@ export class MicrosoftRewardsBot { if (this.config.jobState?.enabled !== false) { this.accountJobState = new JobState(this.config) } + + // Setup automatic scheduler if enabled + if (this.config.scheduling?.enabled) { + const scheduler = new SchedulerManager(this.config) + await scheduler.setup() + } } private shouldSkipAccount(email: string, dayKey: string): boolean { diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 2011790..903d86b 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -31,6 +31,7 @@ export interface Config { dryRun?: boolean; // NEW: Dry-run mode (simulate without executing) queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation dashboard?: ConfigDashboard; // NEW: Local web dashboard for monitoring and control + scheduling?: ConfigScheduling; // NEW: Automatic scheduler configuration (cron/Task Scheduler) } export interface ConfigSaveFingerprint { @@ -195,3 +196,23 @@ export interface ConfigDashboard { port?: number; // dashboard server port (default: 3000) host?: string; // bind address (default: 127.0.0.1) } + +export interface ConfigScheduling { + enabled?: boolean; // enable automatic schedule configuration + type?: 'auto' | 'cron' | 'task-scheduler'; // auto-detect or force specific type + cron?: { + schedule?: string; // cron expression (default: "0 9 * * *") + workingDirectory?: string; // project root path (auto-detected if empty) + nodePath?: string; // path to node executable (auto-detected if empty) + logFile?: string; // optional log file path + user?: string; // optional specific user for crontab + }; + taskScheduler?: { + taskName?: string; // task name in Windows Task Scheduler + schedule?: string; // time in 24h format (e.g., "09:00") + frequency?: 'daily' | 'weekly' | 'once'; // task frequency + workingDirectory?: string; // project root path (auto-detected if empty) + runAsUser?: boolean; // run under current user + highestPrivileges?: boolean; // request highest privileges + }; +} diff --git a/src/util/SchedulerManager.ts b/src/util/SchedulerManager.ts new file mode 100644 index 0000000..5f51ca8 --- /dev/null +++ b/src/util/SchedulerManager.ts @@ -0,0 +1,382 @@ +import { execSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import os from 'os' +import { log } from './Logger' +import type { Config } from '../interface/Config' + +export class SchedulerManager { + private config: Config + private projectRoot: string + private nodePath: string + + constructor(config: Config) { + this.config = config + this.projectRoot = process.cwd() + this.nodePath = process.execPath + } + + async setup(): Promise { + const scheduling = this.config.scheduling + if (!scheduling?.enabled) { + log('main', 'SCHEDULER', 'Automatic scheduling is disabled in config', 'log') + return + } + + const type = scheduling.type || 'auto' + const platform = os.platform() + + log('main', 'SCHEDULER', `Setting up automatic scheduling (type: ${type}, platform: ${platform})`) + + try { + if (type === 'auto') { + if (platform === 'win32') { + await this.setupWindowsTaskScheduler() + } else if (platform === 'linux' || platform === 'darwin') { + await this.setupCron() + } else { + log('main', 'SCHEDULER', `Unsupported platform: ${platform}`, 'warn') + } + } else if (type === 'cron') { + await this.setupCron() + } else if (type === 'task-scheduler') { + await this.setupWindowsTaskScheduler() + } + } catch (error) { + log('main', 'SCHEDULER', `Failed to setup scheduler: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + + private async setupCron(): Promise { + const cronConfig = this.config.scheduling?.cron || {} + const schedule = cronConfig.schedule || '0 9 * * *' + const workingDir = cronConfig.workingDirectory || this.projectRoot + const nodePath = cronConfig.nodePath || this.nodePath + const logFile = cronConfig.logFile || path.join(workingDir, 'logs', 'rewards-cron.log') + const user = cronConfig.user || '' + + log('main', 'SCHEDULER', `Configuring cron with schedule: ${schedule}`) + + // Ensure log directory exists + const logDir = path.dirname(logFile) + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + + // Build cron command + const cronCommand = `${schedule} cd ${workingDir} && ${nodePath} ${path.join(workingDir, 'dist', 'index.js')} >> ${logFile} 2>&1` + + try { + // Check if cron is installed + try { + execSync('which cron', { stdio: 'ignore' }) + } catch { + log('main', 'SCHEDULER', 'cron is not installed. Please install it first: sudo apt-get install cron', 'error') + return + } + + // Get current crontab + let currentCrontab = '' + try { + const getCrontabCmd = user ? `crontab -u ${user} -l` : 'crontab -l' + currentCrontab = execSync(getCrontabCmd, { encoding: 'utf-8' }) + } catch (error) { + // No existing crontab + currentCrontab = '' + } + + // Check if our job already exists + const jobMarker = '# Microsoft-Rewards-Bot' + if (currentCrontab.includes(jobMarker)) { + log('main', 'SCHEDULER', 'Cron job already exists, updating...', 'log') + // Remove old job + const lines = currentCrontab.split('\n').filter(line => + !line.includes(jobMarker) && !line.includes('Microsoft-Rewards-Script') + ) + currentCrontab = lines.join('\n') + } + + // Add new job + const newCrontab = currentCrontab.trim() + '\n' + jobMarker + '\n' + cronCommand + '\n' + + // Write new crontab + const tempFile = path.join(os.tmpdir(), `crontab-${Date.now()}.txt`) + fs.writeFileSync(tempFile, newCrontab) + + const setCrontabCmd = user ? `crontab -u ${user} ${tempFile}` : `crontab ${tempFile}` + execSync(setCrontabCmd) + + // Cleanup temp file + fs.unlinkSync(tempFile) + + log('main', 'SCHEDULER', '✅ Cron job configured successfully', 'log', 'green') + log('main', 'SCHEDULER', `Schedule: ${schedule}`, 'log') + log('main', 'SCHEDULER', `Log file: ${logFile}`, 'log') + log('main', 'SCHEDULER', 'View jobs: crontab -l', 'log') + } catch (error) { + log('main', 'SCHEDULER', `Failed to configure cron: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + + private async setupWindowsTaskScheduler(): Promise { + const taskConfig = this.config.scheduling?.taskScheduler || {} + const taskName = taskConfig.taskName || 'Microsoft-Rewards-Bot' + const schedule = taskConfig.schedule || '09:00' + const frequency = taskConfig.frequency || 'daily' + const workingDir = taskConfig.workingDirectory || this.projectRoot + const runAsUser = taskConfig.runAsUser !== false + const highestPrivileges = taskConfig.highestPrivileges === true + + log('main', 'SCHEDULER', `Configuring Windows Task Scheduler: ${taskName}`) + + try { + // Check if task already exists + const checkCmd = `schtasks /Query /TN "${taskName}" 2>nul` + let taskExists = false + try { + execSync(checkCmd, { stdio: 'ignore' }) + taskExists = true + log('main', 'SCHEDULER', 'Task already exists, it will be updated', 'log') + } catch { + // Task doesn't exist + } + + // Delete existing task if it exists + if (taskExists) { + execSync(`schtasks /Delete /TN "${taskName}" /F`, { stdio: 'ignore' }) + } + + // Build task command + const scriptPath = path.join(workingDir, 'dist', 'index.js') + const action = `"${this.nodePath}" "${scriptPath}"` + + // Create XML for task + const xmlContent = this.generateTaskSchedulerXml( + taskName, + action, + workingDir, + schedule, + frequency, + runAsUser, + highestPrivileges + ) + + // Save XML to temp file + const tempXmlPath = path.join(os.tmpdir(), `task-${Date.now()}.xml`) + fs.writeFileSync(tempXmlPath, xmlContent, 'utf-8') + + // Create task from XML + const createCmd = `schtasks /Create /TN "${taskName}" /XML "${tempXmlPath}" /F` + execSync(createCmd, { stdio: 'ignore' }) + + // Cleanup temp file + fs.unlinkSync(tempXmlPath) + + log('main', 'SCHEDULER', '✅ Windows Task Scheduler configured successfully', 'log', 'green') + log('main', 'SCHEDULER', `Task name: ${taskName}`, 'log') + log('main', 'SCHEDULER', `Schedule: ${frequency} at ${schedule}`, 'log') + log('main', 'SCHEDULER', `View task: Task Scheduler > Task Scheduler Library > ${taskName}`, 'log') + } catch (error) { + log('main', 'SCHEDULER', `Failed to configure Task Scheduler: ${error instanceof Error ? error.message : String(error)}`, 'error') + log('main', 'SCHEDULER', 'Make sure you run this with administrator privileges', 'warn') + } + } + + private generateTaskSchedulerXml( + taskName: string, + action: string, + workingDir: string, + schedule: string, + frequency: string, + runAsUser: boolean, + highestPrivileges: boolean + ): string { + const currentUser = os.userInfo().username + const [hours, minutes] = schedule.split(':') + const startBoundary = `2025-01-01T${hours}:${minutes}:00` + + let triggerXml = '' + if (frequency === 'daily') { + triggerXml = ` + + ${startBoundary} + true + + 1 + + ` + } else if (frequency === 'weekly') { + triggerXml = ` + + ${startBoundary} + true + + 1 + + + + + + + + + + + ` + } + + return ` + + + Microsoft Rewards Bot - Automated task execution + ${currentUser} + + + ${triggerXml} + + + + ${runAsUser ? currentUser : 'SYSTEM'} + InteractiveToken + ${highestPrivileges ? 'HighestAvailable' : 'LeastPrivilege'} + + + + IgnoreNew + false + false + true + true + true + + false + false + + true + true + false + false + false + PT2H + 7 + + + + ${this.nodePath} + "${path.join(workingDir, 'dist', 'index.js')}" + ${workingDir} + + +` + } + + async remove(): Promise { + const platform = os.platform() + log('main', 'SCHEDULER', 'Removing scheduled tasks...') + + try { + if (platform === 'win32') { + await this.removeWindowsTask() + } else if (platform === 'linux' || platform === 'darwin') { + await this.removeCron() + } + } catch (error) { + log('main', 'SCHEDULER', `Failed to remove scheduler: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + + private async removeCron(): Promise { + try { + let currentCrontab = '' + try { + currentCrontab = execSync('crontab -l', { encoding: 'utf-8' }) + } catch { + log('main', 'SCHEDULER', 'No crontab found', 'log') + return + } + + const jobMarker = '# Microsoft-Rewards-Bot' + if (!currentCrontab.includes(jobMarker)) { + log('main', 'SCHEDULER', 'No Microsoft Rewards Bot cron job found', 'log') + return + } + + // Remove job + const lines = currentCrontab.split('\n').filter(line => + !line.includes(jobMarker) && !line.includes('Microsoft-Rewards-Script') + ) + const newCrontab = lines.join('\n') + + const tempFile = path.join(os.tmpdir(), `crontab-${Date.now()}.txt`) + fs.writeFileSync(tempFile, newCrontab) + execSync(`crontab ${tempFile}`) + fs.unlinkSync(tempFile) + + log('main', 'SCHEDULER', '✅ Cron job removed successfully', 'log', 'green') + } catch (error) { + log('main', 'SCHEDULER', `Failed to remove cron: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + + private async removeWindowsTask(): Promise { + const taskConfig = this.config.scheduling?.taskScheduler || {} + const taskName = taskConfig.taskName || 'Microsoft-Rewards-Bot' + + try { + execSync(`schtasks /Delete /TN "${taskName}" /F`, { stdio: 'ignore' }) + log('main', 'SCHEDULER', '✅ Windows Task removed successfully', 'log', 'green') + } catch (error) { + log('main', 'SCHEDULER', `Task "${taskName}" not found or already removed`, 'log') + } + } + + async status(): Promise { + const platform = os.platform() + log('main', 'SCHEDULER', 'Checking scheduler status...') + + try { + if (platform === 'win32') { + await this.statusWindowsTask() + } else if (platform === 'linux' || platform === 'darwin') { + await this.statusCron() + } + } catch (error) { + log('main', 'SCHEDULER', `Failed to check status: ${error instanceof Error ? error.message : String(error)}`, 'error') + } + } + + private async statusCron(): Promise { + try { + const currentCrontab = execSync('crontab -l', { encoding: 'utf-8' }) + const jobMarker = '# Microsoft-Rewards-Bot' + + if (currentCrontab.includes(jobMarker)) { + const lines = currentCrontab.split('\n') + const jobIndex = lines.findIndex(line => line.includes(jobMarker)) + if (jobIndex >= 0 && jobIndex + 1 < lines.length) { + log('main', 'SCHEDULER', '✅ Cron job is active', 'log', 'green') + log('main', 'SCHEDULER', `Job: ${lines[jobIndex + 1]}`, 'log') + } + } else { + log('main', 'SCHEDULER', '❌ No cron job found', 'warn') + } + } catch { + log('main', 'SCHEDULER', '❌ No crontab configured', 'warn') + } + } + + private async statusWindowsTask(): Promise { + const taskConfig = this.config.scheduling?.taskScheduler || {} + const taskName = taskConfig.taskName || 'Microsoft-Rewards-Bot' + + try { + const result = execSync(`schtasks /Query /TN "${taskName}" /FO LIST /V`, { encoding: 'utf-8' }) + if (result.includes(taskName)) { + log('main', 'SCHEDULER', '✅ Windows Task is active', 'log', 'green') + log('main', 'SCHEDULER', `Task name: ${taskName}`, 'log') + } + } catch { + log('main', 'SCHEDULER', `❌ Task "${taskName}" not found`, 'warn') + } + } +}