From 3b06b4ae833e3b62ee82ca9755c3f05e1b83c904 Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Thu, 13 Nov 2025 15:19:35 +0100 Subject: [PATCH] feat: Implement internal scheduler for automatic daily execution with timezone detection --- package-lock.json | 30 +++ package.json | 4 +- src/config.jsonc | 23 +- src/index.ts | 48 +++- src/interface/Config.ts | 20 +- src/scheduler/InternalScheduler.ts | 286 ++++++++++++++++++++++++ src/scheduler/README.md | 259 +++++++++++++++++++++ src/util/state/Load.ts | 37 +-- src/util/validation/StartupValidator.ts | 10 - 9 files changed, 637 insertions(+), 80 deletions(-) create mode 100644 src/scheduler/InternalScheduler.ts create mode 100644 src/scheduler/README.md diff --git a/package-lock.json b/package-lock.json index 21ae579..591d4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "https-proxy-agent": "^7.0.6", "luxon": "^3.5.0", "ms": "^2.1.3", + "node-cron": "3.0.3", "playwright": "1.52.0", "rebrowser-playwright": "1.52.0", "socks-proxy-agent": "^8.0.5", @@ -30,6 +31,7 @@ "@types/express": "^4.17.25", "@types/ms": "^0.7.34", "@types/node": "^20.19.24", + "@types/node-cron": "3.0.11", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.17.0", "eslint": "^8.57.0", @@ -452,6 +454,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2879,6 +2888,18 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -4005,6 +4026,15 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 88756a3..9dd3a49 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/express": "^4.17.25", "@types/ms": "^0.7.34", "@types/node": "^20.19.24", + "@types/node-cron": "3.0.11", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.17.0", "eslint": "^8.57.0", @@ -75,10 +76,11 @@ "https-proxy-agent": "^7.0.6", "luxon": "^3.5.0", "ms": "^2.1.3", + "node-cron": "3.0.3", "playwright": "1.52.0", "rebrowser-playwright": "1.52.0", "socks-proxy-agent": "^8.0.5", "ts-node": "^10.9.2", "ws": "^8.18.3" } -} \ No newline at end of file +} diff --git a/src/config.jsonc b/src/config.jsonc index ac26afa..797794f 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -138,25 +138,12 @@ "host": "127.0.0.1" }, // === SCHEDULING === - // See docs/getting-started.md for setup instructions + // Automatic daily execution at specified time + // The bot will run once per day at the time you specify + // Time is based on YOUR computer/server timezone (automatically detected) "scheduling": { - "enabled": false, - "type": "auto", - "cron": { - "schedule": "0 9 * * *", - "workingDirectory": "", - "nodePath": "", - "logFile": "", - "user": "" - }, - "taskScheduler": { - "taskName": "Microsoft-Rewards-Bot", - "schedule": "09:00", - "frequency": "daily", - "workingDirectory": "", - "runAsUser": true, - "highestPrivileges": false - } + "enabled": false, // Set to true to enable automatic daily runs + "time": "09:00" // Time in 24h format (HH:MM) - e.g., "09:00" = 9 AM, "21:30" = 9:30 PM }, // === UPDATES === "update": { diff --git a/src/index.ts b/src/index.ts index 4a24fdf..3246c9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ import { DesktopFlow } from './flows/DesktopFlow' import { MobileFlow } from './flows/MobileFlow' import { SummaryReporter, type AccountResult } from './flows/SummaryReporter' +import { InternalScheduler } from './scheduler/InternalScheduler' + import { DISCORD, TIMEOUTS } from './constants' import { Account } from './interface/Account' @@ -110,9 +112,6 @@ export class MicrosoftRewardsBot { if (this.config.jobState?.enabled !== false) { this.accountJobState = new JobState(this.config) } - - // Note: Legacy SchedulerManager removed - use OS scheduler (cron/Task Scheduler) instead - // See docs/schedule.md for configuration } private shouldSkipAccount(email: string, dayKey: string): boolean { @@ -879,6 +878,9 @@ async function main(): Promise { const crashState = { restarts: 0 } const config = rewardsBot.config + // Scheduler instance (initialized in bootstrap if enabled) + let scheduler: InternalScheduler | null = null + // Auto-start dashboard if enabled in config if (config.dashboard?.enabled) { const { DashboardServer } = await import('./dashboard/server') @@ -908,22 +910,26 @@ async function main(): Promise { const errorMsg = reason instanceof Error ? reason.message : String(reason) const stack = reason instanceof Error ? reason.stack : undefined log('main', 'FATAL', `UnhandledRejection: ${errorMsg}${stack ? `\nStack: ${stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error') - stopWebhookCleanup() // CLEANUP FIX: Stop webhook cleanup interval + scheduler?.stop() // Stop scheduler before exit + stopWebhookCleanup() gracefulExit(1) }) process.on('uncaughtException', (err: Error) => { log('main', 'FATAL', `UncaughtException: ${err.message}${err.stack ? `\nStack: ${err.stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error') - stopWebhookCleanup() // CLEANUP FIX: Stop webhook cleanup interval + scheduler?.stop() // Stop scheduler before exit + stopWebhookCleanup() gracefulExit(1) }) process.on('SIGTERM', () => { log('main', 'SHUTDOWN', 'Received SIGTERM, shutting down gracefully...', 'log') - stopWebhookCleanup() // CLEANUP FIX: Stop webhook cleanup interval + scheduler?.stop() // Stop scheduler before exit + stopWebhookCleanup() gracefulExit(0) }) process.on('SIGINT', () => { log('main', 'SHUTDOWN', 'Received SIGINT (Ctrl+C), shutting down gracefully...', 'log') - stopWebhookCleanup() // CLEANUP FIX: Stop webhook cleanup interval + scheduler?.stop() // Stop scheduler before exit + stopWebhookCleanup() gracefulExit(0) }) } @@ -1025,6 +1031,34 @@ async function main(): Promise { log('main', 'UPDATE', `Update check failed (continuing): ${updateError instanceof Error ? updateError.message : String(updateError)}`, 'warn') } + // Check if scheduling is enabled + if (config.scheduling?.enabled) { + // Initialize scheduler + scheduler = new InternalScheduler(config, async () => { + try { + await rewardsBot.initialize() + await rewardsBot.run() + } catch (error) { + log('main', 'SCHEDULER-TASK', `Scheduled run failed: ${error instanceof Error ? error.message : String(error)}`, 'error') + throw error // Re-throw for scheduler retry logic + } + }) + + const schedulerStarted = scheduler.start() + + if (schedulerStarted) { + log('main', 'MAIN', 'Bot running in scheduled mode. Process will stay alive.', 'log', 'green') + log('main', 'MAIN', 'Press CTRL+C to stop the scheduler and exit.', 'log', 'cyan') + // Keep process alive - scheduler handles execution + return + } else { + log('main', 'MAIN', 'Scheduler failed to start. Running one-time execution instead.', 'warn') + scheduler = null + // Continue with one-time execution below + } + } + + // One-time execution (scheduling disabled or failed to start) await rewardsBot.initialize() await rewardsBot.run() } catch (e) { diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 7723971..07f006a 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -193,22 +193,10 @@ export interface ConfigDashboard { } 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 + enabled?: boolean; // Enable automatic daily scheduling + time?: string; // Daily execution time in 24h format (HH:MM) - e.g., "09:00" for 9 AM (RECOMMENDED) + cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED + schedule?: string; // Cron expression - e.g., "0 9 * * *" for 9 AM daily }; } diff --git a/src/scheduler/InternalScheduler.ts b/src/scheduler/InternalScheduler.ts new file mode 100644 index 0000000..362df02 --- /dev/null +++ b/src/scheduler/InternalScheduler.ts @@ -0,0 +1,286 @@ +import cron from 'node-cron' +import type { Config } from '../interface/Config' +import { log } from '../util/notifications/Logger' + +/** + * Internal Scheduler for automatic bot execution + * Uses node-cron internally but provides simple time-based scheduling + * + * Features: + * - Simple time-based scheduling (e.g., "09:00" = daily at 9 AM) + * - Automatic timezone detection (uses your computer/server timezone) + * - Overlap protection (prevents concurrent runs) + * - Error recovery with retries + * - Clean shutdown handling + * - Cross-platform (Windows, Linux, Mac) + */ +export class InternalScheduler { + private cronJob: cron.ScheduledTask | null = null + private config: Config + private taskCallback: () => Promise + private isRunning: boolean = false + private lastRunTime: Date | null = null + + constructor(config: Config, taskCallback: () => Promise) { + this.config = config + this.taskCallback = taskCallback + } + + /** + * Start the scheduler if enabled in config + * @returns true if scheduler started successfully, false otherwise + */ + public start(): boolean { + const scheduleConfig = this.config.scheduling + + // Validation checks + if (!scheduleConfig?.enabled) { + log('main', 'SCHEDULER', 'Internal scheduler disabled (scheduling.enabled = false)') + return false + } + + // Get schedule from simple time format (e.g., "09:00") or fallback to cron format + const schedule = this.parseSchedule(scheduleConfig) + + if (!schedule) { + log('main', 'SCHEDULER', 'Invalid schedule format. Use time in HH:MM format (e.g., "09:00" for 9 AM)', 'error') + return false + } + + // Validate cron expression + if (!cron.validate(schedule)) { + log('main', 'SCHEDULER', `Invalid schedule: "${schedule}"`, 'error') + return false + } + + try { + const timezone = this.detectTimezone() + + this.cronJob = cron.schedule(schedule, async () => { + await this.runScheduledTask() + }, { + scheduled: true, + timezone + }) + + const displayTime = scheduleConfig.time || this.extractTimeFromCron(schedule) + + log('main', 'SCHEDULER', '✓ Internal scheduler started', 'log', 'green') + log('main', 'SCHEDULER', ` Daily run time: ${displayTime}`, 'log', 'cyan') + log('main', 'SCHEDULER', ` Timezone: ${timezone}`, 'log', 'cyan') + log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan') + + return true + } catch (error) { + log('main', 'SCHEDULER', `Failed to start scheduler: ${error instanceof Error ? error.message : String(error)}`, 'error') + return false + } + } + + /** + * Parse schedule from config - supports simple time format (HH:MM) or cron expression + * @returns Cron expression string + */ + private parseSchedule(scheduleConfig: { time?: string; cron?: { schedule?: string } }): string | null { + // Priority 1: Simple time format (e.g., "09:00") + if (scheduleConfig.time) { + const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim()) + if (timeMatch) { + const hours = parseInt(timeMatch[1]!, 10) + const minutes = parseInt(timeMatch[2]!, 10) + + if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + // Convert to cron: "minute hour * * *" = daily at specified time + return `${minutes} ${hours} * * *` + } + } + return null // Invalid time format + } + + // Priority 2: Legacy cron format (for backwards compatibility) + if (scheduleConfig.cron?.schedule) { + return scheduleConfig.cron.schedule + } + + // Default: 9 AM daily + return '0 9 * * *' + } + + /** + * Extract readable time from cron expression (for display purposes) + */ + private extractTimeFromCron(cronExpr: string): string { + const parts = cronExpr.split(' ') + if (parts.length >= 2) { + const minute = parts[0] + const hour = parts[1] + if (minute && hour && minute !== '*' && hour !== '*') { + return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}` + } + } + return cronExpr + } + + /** + * Execute the scheduled task with overlap protection and retry logic + */ + private async runScheduledTask(): Promise { + // Overlap protection + if (this.isRunning) { + log('main', 'SCHEDULER', 'Skipping scheduled run: previous task still running', 'warn') + return + } + + const maxRetries = this.config.crashRecovery?.maxRestarts ?? 1 + let attempts = 0 + + while (attempts <= maxRetries) { + try { + this.isRunning = true + this.lastRunTime = new Date() + + log('main', 'SCHEDULER', '⏰ Scheduled run triggered', 'log', 'cyan') + + await this.taskCallback() + + log('main', 'SCHEDULER', '✓ Scheduled run completed successfully', 'log', 'green') + log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan') + + return // Success - exit retry loop + + } catch (error) { + attempts++ + const errorMsg = error instanceof Error ? error.message : String(error) + log('main', 'SCHEDULER', `Scheduled run failed (attempt ${attempts}/${maxRetries + 1}): ${errorMsg}`, 'error') + + if (attempts <= maxRetries) { + const backoff = (this.config.crashRecovery?.backoffBaseMs ?? 2000) * attempts + log('main', 'SCHEDULER', `Retrying in ${backoff}ms...`, 'warn') + await new Promise(resolve => setTimeout(resolve, backoff)) + } else { + log('main', 'SCHEDULER', `Max retries (${maxRetries + 1}) exceeded. Waiting for next scheduled run.`, 'error') + } + } finally { + this.isRunning = false + } + } + } + + /** + * Stop the scheduler gracefully + */ + public stop(): void { + if (this.cronJob) { + this.cronJob.stop() + log('main', 'SCHEDULER', 'Scheduler stopped', 'warn') + this.cronJob = null + } + } + + /** + * Get the next scheduled run time + */ + private getNextRunTime(): string { + if (!this.cronJob) return 'unknown' + + try { + const scheduleConfig = this.config.scheduling + const timezone = this.detectTimezone() + + // Get the cron schedule being used + let cronSchedule: string + if (scheduleConfig?.time) { + cronSchedule = this.parseSchedule(scheduleConfig) || '0 9 * * *' + } else if (scheduleConfig?.cron?.schedule) { + cronSchedule = scheduleConfig.cron.schedule + } else { + cronSchedule = '0 9 * * *' + } + + // Calculate next run based on cron expression + const now = new Date() + const parts = cronSchedule.split(' ') + + if (parts.length !== 5) { + return 'invalid schedule format' + } + + // Simple next-run calculation for daily schedules + const [minute, hour] = parts + + if (!minute || !hour || minute === '*' || hour === '*') { + return 'varies (see schedule configuration)' + } + + const targetHour = parseInt(hour, 10) + const targetMinute = parseInt(minute, 10) + + if (isNaN(targetHour) || isNaN(targetMinute)) { + return 'complex schedule' + } + + const next = new Date(now) + next.setHours(targetHour, targetMinute, 0, 0) + + // If time already passed today, move to tomorrow + if (next <= now) { + next.setDate(next.getDate() + 1) + } + + return next.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: timezone + }) + } catch { + return 'unable to calculate' + } + } + + /** + * Detect system timezone + */ + private detectTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } + } + + /** + * Check if scheduler is active + */ + public isActive(): boolean { + return this.cronJob !== null + } + + /** + * Get scheduler status for monitoring + */ + public getStatus(): { + active: boolean + isRunning: boolean + lastRun: string | null + nextRun: string + } { + return { + active: this.isActive(), + isRunning: this.isRunning, + lastRun: this.lastRunTime ? this.lastRunTime.toLocaleString() : null, + nextRun: this.getNextRunTime() + } + } + + /** + * Trigger an immediate run (useful for manual triggers or dashboard) + */ + public async triggerNow(): Promise { + log('main', 'SCHEDULER', 'Manual trigger requested', 'log', 'cyan') + await this.runScheduledTask() + } +} diff --git a/src/scheduler/README.md b/src/scheduler/README.md new file mode 100644 index 0000000..6ffcee1 --- /dev/null +++ b/src/scheduler/README.md @@ -0,0 +1,259 @@ +# Internal Scheduler + +## Overview + +The **Internal Scheduler** is a cross-platform scheduling system built into the bot. It automatically runs the bot daily at a specified time - **no external cron setup required, no Task Scheduler configuration needed**. + +## Features + +✅ **Simple time format** - Just specify "09:00" for 9 AM +✅ **Automatic timezone detection** - Uses your computer/server timezone automatically +✅ **Cross-platform** - Works on Windows, Linux, and macOS +✅ **Zero external configuration** - No cron or Task Scheduler setup required +✅ **Overlap protection** - Prevents concurrent runs +✅ **Automatic retry** - Retries failed runs with exponential backoff +✅ **Clean shutdown** - Gracefully stops on CTRL+C or SIGTERM + +## Quick Start + +### 1. Enable Scheduling + +Edit `src/config.jsonc`: + +```jsonc +{ + "scheduling": { + "enabled": true, + "time": "09:00" // Daily at 9:00 AM (your local time) + } +} +``` + +### 2. Run the Bot + +```bash +npm start +``` + +The bot will: +- Start the internal scheduler +- Detect your timezone automatically +- Keep the process alive +- Execute automatically daily at the specified time +- Log each scheduled run + +### 3. Stop the Bot + +Press **CTRL+C** to stop the scheduler and exit cleanly. + +## Time Format Examples + +| Time | Description | +|------|-------------| +| `"09:00"` | Daily at 9:00 AM | +| `"21:30"` | Daily at 9:30 PM | +| `"00:00"` | Daily at midnight | +| `"12:00"` | Daily at noon | +| `"06:15"` | Daily at 6:15 AM | + +**Note:** Always use 24-hour format (HH:MM). The scheduler automatically uses your system's timezone. + +## Configuration Options + +### `scheduling.enabled` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** Enable/disable automatic scheduling + +### `scheduling.time` +- **Type:** `string` +- **Format:** `"HH:MM"` (24-hour format) +- **Default:** `"09:00"` +- **Description:** Daily execution time in your local timezone +- **Examples:** `"09:00"`, `"21:30"`, `"00:00"` + +### Legacy `scheduling.cron.schedule` (deprecated) +- **Type:** `string` (cron expression) +- **Description:** Advanced cron format (e.g., `"0 9 * * *"`) +- **Note:** Supported for backwards compatibility. Use `time` instead for simplicity. + +## Timezone Detection + +The scheduler **automatically detects your timezone** using your computer/server's system settings: + +- **Windows:** Uses Windows timezone settings +- **Linux/Mac:** Uses system timezone (usually from `/etc/localtime`) +- **Docker:** Uses container timezone (set via `TZ` environment variable) + +**You don't need to configure timezone manually!** The bot logs the detected timezone on startup: + +``` +✓ Internal scheduler started + Daily run time: 09:00 + Timezone: America/New_York + Next run: Thu, Jan 18, 2024, 09:00 AM +``` + +## How It Works + +1. **Initialization** + - Bot reads `config.scheduling` + - Detects your timezone automatically + - Creates scheduler for daily execution + - Validates time format + +2. **Execution** + - Scheduler waits until next scheduled time + - Triggers bot execution (`initialize()` + `run()`) + - Logs start/completion/errors + - Calculates next run time + +3. **Error Handling** + - Failed runs are retried (using `config.crashRecovery` settings) + - Overlap protection prevents concurrent runs + - Errors are logged but don't stop the scheduler + +4. **Shutdown** + - SIGINT/SIGTERM signals stop the scheduler + - Current task completes before exit + - Cleanup handlers run normally + +## Advantages Over OS Schedulers + +| Feature | Internal Scheduler | OS Scheduler | +|---------|-------------------|--------------| +| Setup complexity | ✅ Simple | ❌ Manual config | +| Time format | ✅ Simple (09:00) | ❌ Complex cron | +| Windows support | ✅ Auto | ❌ Task Scheduler setup | +| Linux support | ✅ Auto | ⚠️ crontab config | +| Timezone handling | ✅ Automatic | ❌ Manual | +| Centralized logs | ✅ Yes | ❌ Separate logs | +| Dashboard integration | ✅ Possible | ❌ No | +| Process management | ⚠️ Must stay alive | ✅ Cron handles | + +## Docker Users + +Docker users can **continue using cron** if preferred (see `docker/` directory). The internal scheduler is **optional** and doesn't interfere with Docker cron setup. + +**Docker Timezone Setup:** +```yaml +# docker/compose.yaml +environment: + - TZ=America/New_York # Set your timezone + - RUN_ON_START=true +``` + +## Troubleshooting + +### Scheduler doesn't start +- Check `scheduling.enabled` is `true` +- Verify `time` is in HH:MM format (e.g., `"09:00"`) +- Look for errors in console output + +### Wrong timezone detected +- Check your system timezone settings +- For Docker, set `TZ` environment variable +- Verify in logs: "Timezone: YOUR_TIMEZONE" + +### Runs don't trigger at expected time +- Verify time format is 24-hour (e.g., `"21:00"` not `"9:00 PM"`) +- Check system clock is correct +- Wait for the next scheduled time (check logs for "Next run") + +### Process exits unexpectedly +- Check `jobState.autoResetOnComplete` is `true` (for scheduled runs) +- Review crash logs +- Ensure no SIGTERM/SIGINT signals from system + +## Advanced: Cron Format (Legacy) + +For users who need complex schedules (e.g., multiple times per day, specific weekdays), you can use the legacy cron format: + +```jsonc +{ + "scheduling": { + "enabled": true, + "cron": { + "schedule": "0 9,21 * * *" // Daily at 9 AM and 9 PM + } + } +} +``` + +**Cron Expression Examples:** + +| Schedule | Cron Expression | Description | +|----------|----------------|-------------| +| Every day at 9 AM | `0 9 * * *` | Once daily | +| Every 6 hours | `0 */6 * * *` | 4 times a day | +| Twice daily (9 AM, 9 PM) | `0 9,21 * * *` | Morning & evening | +| Weekdays at 8 AM | `0 8 * * 1-5` | Monday-Friday only | +| Every 30 minutes | `*/30 * * * *` | 48 times a day | + +**Note:** Use [crontab.guru](https://crontab.guru) to create and validate cron expressions. + +## API Reference + +### `InternalScheduler.start()` +Starts the scheduler. Returns `true` if successful. + +### `InternalScheduler.stop()` +Stops the scheduler gracefully. + +### `InternalScheduler.isActive()` +Returns `true` if scheduler is running. + +### `InternalScheduler.getStatus()` +Returns scheduler status object: +```typescript +{ + active: boolean + isRunning: boolean + lastRun: string | null + nextRun: string +} +``` + +### `InternalScheduler.triggerNow()` +Manually trigger an immediate run (useful for dashboard). + +## Related Files + +- **Implementation:** `src/scheduler/InternalScheduler.ts` +- **Integration:** `src/index.ts` (main function) +- **Config:** `src/config.jsonc` +- **Interface:** `src/interface/Config.ts` (ConfigScheduling) + +## Example: Complete Setup + +```jsonc +// src/config.jsonc +{ + "scheduling": { + "enabled": true, + "time": "09:00" // Daily at 9 AM (automatic timezone) + }, + "jobState": { + "enabled": true, + "autoResetOnComplete": true // Reset state after each scheduled run + }, + "crashRecovery": { + "enabled": true, + "maxRetries": 3 // Retry failed runs up to 3 times + } +} +``` + +Then run: +```bash +npm start +``` + +You'll see: +``` +✓ Internal scheduler started + Daily run time: 09:00 + Timezone: America/New_York + Next run: Thu, Jan 18, 2024, 09:00 AM +Bot is ready and waiting for scheduled execution... +``` diff --git a/src/util/state/Load.ts b/src/util/state/Load.ts index 4cbba44..a4979cb 100644 --- a/src/util/state/Load.ts +++ b/src/util/state/Load.ts @@ -257,44 +257,25 @@ function extractStringField(obj: unknown, key: string): string | undefined { return undefined } -function extractBooleanField(obj: unknown, key: string): boolean | undefined { - if (obj && typeof obj === 'object' && key in obj) { - const value = (obj as Record)[key] - return typeof value === 'boolean' ? value : undefined - } - return undefined -} - function buildSchedulingConfig(raw: unknown): ConfigScheduling | undefined { if (!raw || typeof raw !== 'object') return undefined const source = raw as Record const scheduling: ConfigScheduling = { - enabled: source.enabled === true, - type: typeof source.type === 'string' ? source.type as ConfigScheduling['type'] : undefined + enabled: source.enabled === true } + // Priority 1: Simple time format (recommended) + const timeField = extractStringField(source, 'time') + if (timeField) { + scheduling.time = timeField + } + + // Priority 2: Legacy cron format (backwards compatibility) const cronRaw = source.cron if (cronRaw && typeof cronRaw === 'object') { scheduling.cron = { - schedule: extractStringField(cronRaw, 'schedule'), - workingDirectory: extractStringField(cronRaw, 'workingDirectory'), - nodePath: extractStringField(cronRaw, 'nodePath'), - logFile: extractStringField(cronRaw, 'logFile'), - user: extractStringField(cronRaw, 'user') - } - } - - const taskRaw = source.taskScheduler - if (taskRaw && typeof taskRaw === 'object') { - const taskSource = taskRaw as Record - scheduling.taskScheduler = { - taskName: extractStringField(taskRaw, 'taskName'), - schedule: extractStringField(taskRaw, 'schedule'), - frequency: typeof taskSource.frequency === 'string' ? taskSource.frequency as 'daily' | 'weekly' | 'once' : undefined, - workingDirectory: extractStringField(taskRaw, 'workingDirectory'), - runAsUser: extractBooleanField(taskRaw, 'runAsUser'), - highestPrivileges: extractBooleanField(taskRaw, 'highestPrivileges') + schedule: extractStringField(cronRaw, 'schedule') } } diff --git a/src/util/validation/StartupValidator.ts b/src/util/validation/StartupValidator.ts index 446334e..cf0c1c4 100644 --- a/src/util/validation/StartupValidator.ts +++ b/src/util/validation/StartupValidator.ts @@ -179,16 +179,6 @@ 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.browser?.headless === false) { this.addWarning(