mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
feat: Implement internal scheduler for automatic daily execution with timezone detection
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
48
src/index.ts
48
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
286
src/scheduler/InternalScheduler.ts
Normal file
286
src/scheduler/InternalScheduler.ts
Normal file
@@ -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<void>
|
||||
private isRunning: boolean = false
|
||||
private lastRunTime: Date | null = null
|
||||
|
||||
constructor(config: Config, taskCallback: () => Promise<void>) {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
log('main', 'SCHEDULER', 'Manual trigger requested', 'log', 'cyan')
|
||||
await this.runScheduledTask()
|
||||
}
|
||||
}
|
||||
259
src/scheduler/README.md
Normal file
259
src/scheduler/README.md
Normal file
@@ -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...
|
||||
```
|
||||
@@ -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<string, unknown>)[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<string, unknown>
|
||||
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<string, unknown>
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user