mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 17:26:17 +00:00
feat: add jitter support for scheduling to randomize execution times
This commit is contained in:
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
// === DASHBOARD ===
|
// === DASHBOARD ===
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"enabled": false,
|
"enabled": false, // autostart the dashboard with the bot
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"host": "127.0.0.1"
|
"host": "127.0.0.1"
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,12 @@
|
|||||||
// Time is based on YOUR computer/server timezone (automatically detected)
|
// Time is based on YOUR computer/server timezone (automatically detected)
|
||||||
"scheduling": {
|
"scheduling": {
|
||||||
"enabled": false, // Set to true to enable automatic daily runs
|
"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
|
"time": "09:00", // Time in 24h format (HH:MM) - e.g., "09:00" = 9 AM, "21:30" = 9:30 PM
|
||||||
|
"jitter": {
|
||||||
|
"enabled": false, // If true, apply a small daily offset to avoid exact-time runs
|
||||||
|
"minMinutesBefore": 40, // Max minutes to start before the scheduled time
|
||||||
|
"maxMinutesAfter": 20 // Max minutes to start after the scheduled time
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// === UPDATES ===
|
// === UPDATES ===
|
||||||
"update": {
|
"update": {
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ export interface ConfigScheduling {
|
|||||||
cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED
|
cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED
|
||||||
schedule?: string; // Cron expression - e.g., "0 9 * * *" for 9 AM daily
|
schedule?: string; // Cron expression - e.g., "0 9 * * *" for 9 AM daily
|
||||||
};
|
};
|
||||||
|
jitter?: {
|
||||||
|
enabled?: boolean; // If true, apply random +/- offset around scheduled time
|
||||||
|
minMinutesBefore?: number; // How many minutes before the scheduled time we may start (default 20)
|
||||||
|
maxMinutesAfter?: number; // How many minutes after the scheduled time we may start (default 30)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigErrorReporting {
|
export interface ConfigErrorReporting {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class InternalScheduler {
|
|||||||
private taskCallback: () => Promise<void>
|
private taskCallback: () => Promise<void>
|
||||||
private isRunning: boolean = false
|
private isRunning: boolean = false
|
||||||
private lastRunTime: Date | null = null
|
private lastRunTime: Date | null = null
|
||||||
|
private lastCronExpression: string | null = null
|
||||||
|
|
||||||
constructor(config: Config, taskCallback: () => Promise<void>) {
|
constructor(config: Config, taskCallback: () => Promise<void>) {
|
||||||
this.config = config
|
this.config = config
|
||||||
@@ -39,34 +40,36 @@ export class InternalScheduler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get schedule from simple time format (e.g., "09:00") or fallback to cron format
|
// Get schedule from simple time format (e.g., "09:00") or fallback to cron format (supports jitter)
|
||||||
const schedule = this.parseSchedule(scheduleConfig)
|
const { cronExpr, displayTime, jitterApplied } = this.buildSchedule(scheduleConfig)
|
||||||
|
|
||||||
if (!schedule) {
|
if (!cronExpr) {
|
||||||
log('main', 'SCHEDULER', 'Invalid schedule format. Use time in HH:MM format (e.g., "09:00" for 9 AM)', 'error')
|
log('main', 'SCHEDULER', 'Invalid schedule format. Use time in HH:MM format (e.g., "09:00" for 9 AM)', 'error')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate cron expression
|
// Validate cron expression
|
||||||
if (!cron.validate(schedule)) {
|
if (!cron.validate(cronExpr)) {
|
||||||
log('main', 'SCHEDULER', `Invalid schedule: "${schedule}"`, 'error')
|
log('main', 'SCHEDULER', `Invalid schedule: "${cronExpr}"`, 'error')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timezone = this.detectTimezone()
|
const timezone = this.detectTimezone()
|
||||||
|
|
||||||
this.cronJob = cron.schedule(schedule, async () => {
|
this.cronJob = cron.schedule(cronExpr, async () => {
|
||||||
await this.runScheduledTask()
|
await this.runScheduledTask()
|
||||||
}, {
|
}, {
|
||||||
scheduled: true,
|
scheduled: true,
|
||||||
timezone
|
timezone
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayTime = scheduleConfig.time || this.extractTimeFromCron(schedule)
|
this.lastCronExpression = cronExpr
|
||||||
|
const timeLabel = displayTime || this.extractTimeFromCron(cronExpr)
|
||||||
|
const jitterLabel = jitterApplied ? ` (jitter applied: ${jitterApplied} min)` : ''
|
||||||
|
|
||||||
log('main', 'SCHEDULER', '✓ Internal scheduler started', 'log', 'green')
|
log('main', 'SCHEDULER', '✓ Internal scheduler started', 'log', 'green')
|
||||||
log('main', 'SCHEDULER', ` Daily run time: ${displayTime}`, 'log', 'cyan')
|
log('main', 'SCHEDULER', ` Daily run time: ${timeLabel}${jitterLabel}`, 'log', 'cyan')
|
||||||
log('main', 'SCHEDULER', ` Timezone: ${timezone}`, 'log', 'cyan')
|
log('main', 'SCHEDULER', ` Timezone: ${timezone}`, 'log', 'cyan')
|
||||||
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
|
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ export class InternalScheduler {
|
|||||||
* Parse schedule from config - supports simple time format (HH:MM) or cron expression
|
* Parse schedule from config - supports simple time format (HH:MM) or cron expression
|
||||||
* @returns Cron expression string
|
* @returns Cron expression string
|
||||||
*/
|
*/
|
||||||
private parseSchedule(scheduleConfig: { time?: string; cron?: { schedule?: string } }): string | null {
|
private buildSchedule(scheduleConfig: { time?: string; cron?: { schedule?: string }; jitter?: { enabled?: boolean; minMinutesBefore?: number; maxMinutesAfter?: number } }): { cronExpr: string | null; displayTime: string; jitterApplied: number } {
|
||||||
// Priority 1: Simple time format (e.g., "09:00")
|
// Priority 1: Simple time format (e.g., "09:00")
|
||||||
if (scheduleConfig.time) {
|
if (scheduleConfig.time) {
|
||||||
const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim())
|
const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim())
|
||||||
@@ -90,20 +93,53 @@ export class InternalScheduler {
|
|||||||
const minutes = parseInt(timeMatch[2]!, 10)
|
const minutes = parseInt(timeMatch[2]!, 10)
|
||||||
|
|
||||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||||
// Convert to cron: "minute hour * * *" = daily at specified time
|
const jitter = this.applyJitter(hours, minutes, scheduleConfig.jitter)
|
||||||
return `${minutes} ${hours} * * *`
|
const cronExpr = `${jitter.minute} ${jitter.hour} * * *`
|
||||||
|
const display = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
return { cronExpr, displayTime: display, jitterApplied: jitter.offsetMinutes }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null // Invalid time format
|
return { cronExpr: null, displayTime: '', jitterApplied: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58)
|
// Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58)
|
||||||
if (scheduleConfig.cron?.schedule) {
|
if (scheduleConfig.cron?.schedule) {
|
||||||
return scheduleConfig.cron.schedule
|
return { cronExpr: scheduleConfig.cron.schedule, displayTime: this.extractTimeFromCron(scheduleConfig.cron.schedule), jitterApplied: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: 9 AM daily
|
// Default: 9 AM daily
|
||||||
return '0 9 * * *'
|
const jitter = this.applyJitter(9, 0, scheduleConfig.jitter)
|
||||||
|
return { cronExpr: `${jitter.minute} ${jitter.hour} * * *`, displayTime: '09:00', jitterApplied: jitter.offsetMinutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyJitter(hour: number, minute: number, jitter?: { enabled?: boolean; minMinutesBefore?: number; maxMinutesAfter?: number }) {
|
||||||
|
const enabled = jitter?.enabled === true
|
||||||
|
const before = Number.isFinite(jitter?.minMinutesBefore) ? Number(jitter!.minMinutesBefore) : 20
|
||||||
|
const after = Number.isFinite(jitter?.maxMinutesAfter) ? Number(jitter!.maxMinutesAfter) : 30
|
||||||
|
|
||||||
|
if (!enabled || (before === 0 && after === 0)) {
|
||||||
|
return { hour, minute, offsetMinutes: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const minOffset = -Math.abs(before)
|
||||||
|
const maxOffset = Math.abs(after)
|
||||||
|
const offset = this.getRandomInt(minOffset, maxOffset)
|
||||||
|
|
||||||
|
let totalMinutes = hour * 60 + minute + offset
|
||||||
|
const minutesInDay = 24 * 60
|
||||||
|
while (totalMinutes < 0) totalMinutes += minutesInDay
|
||||||
|
while (totalMinutes >= minutesInDay) totalMinutes -= minutesInDay
|
||||||
|
|
||||||
|
const jitteredHour = Math.floor(totalMinutes / 60)
|
||||||
|
const jitteredMinute = totalMinutes % 60
|
||||||
|
|
||||||
|
return { hour: jitteredHour, minute: jitteredMinute, offsetMinutes: offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRandomInt(minInclusive: number, maxInclusive: number): number {
|
||||||
|
const min = Math.ceil(minInclusive)
|
||||||
|
const max = Math.floor(maxInclusive)
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +182,7 @@ export class InternalScheduler {
|
|||||||
log('main', 'SCHEDULER', '✓ Scheduled run completed successfully', 'log', 'green')
|
log('main', 'SCHEDULER', '✓ Scheduled run completed successfully', 'log', 'green')
|
||||||
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
|
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
|
||||||
|
|
||||||
|
this.rescheduleWithJitter()
|
||||||
return // Success - exit retry loop
|
return // Success - exit retry loop
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -164,6 +201,9 @@ export class InternalScheduler {
|
|||||||
this.isRunning = false
|
this.isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we exit the loop without success, still reschedule jitter for the next day
|
||||||
|
this.rescheduleWithJitter()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,6 +217,36 @@ export class InternalScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rescheduleWithJitter(): void {
|
||||||
|
const scheduleConfig = this.config.scheduling
|
||||||
|
// Only apply jitter for simple time-based schedules
|
||||||
|
if (!scheduleConfig?.enabled || !scheduleConfig.time) return
|
||||||
|
|
||||||
|
const { cronExpr, displayTime, jitterApplied } = this.buildSchedule(scheduleConfig)
|
||||||
|
if (!cronExpr || !cron.validate(cronExpr)) {
|
||||||
|
log('main', 'SCHEDULER', 'Jitter reschedule skipped due to invalid schedule', 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cronJob) {
|
||||||
|
this.cronJob.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = this.detectTimezone()
|
||||||
|
this.cronJob = cron.schedule(cronExpr, async () => {
|
||||||
|
await this.runScheduledTask()
|
||||||
|
}, {
|
||||||
|
scheduled: true,
|
||||||
|
timezone
|
||||||
|
})
|
||||||
|
|
||||||
|
this.lastCronExpression = cronExpr
|
||||||
|
const timeLabel = displayTime || this.extractTimeFromCron(cronExpr)
|
||||||
|
const jitterLabel = jitterApplied ? ` (jitter applied: ${jitterApplied} min)` : ''
|
||||||
|
|
||||||
|
log('main', 'SCHEDULER', `Jitter rescheduled next run at ${timeLabel}${jitterLabel}. Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the next scheduled run time
|
* Get the next scheduled run time
|
||||||
*/
|
*/
|
||||||
@@ -188,14 +258,10 @@ export class InternalScheduler {
|
|||||||
const timezone = this.detectTimezone()
|
const timezone = this.detectTimezone()
|
||||||
|
|
||||||
// Get the cron schedule being used
|
// Get the cron schedule being used
|
||||||
let cronSchedule: string
|
const cronSchedule = this.lastCronExpression
|
||||||
if (scheduleConfig?.time) {
|
?? (scheduleConfig?.time ? this.buildSchedule(scheduleConfig).cronExpr : undefined)
|
||||||
cronSchedule = this.parseSchedule(scheduleConfig) || '0 9 * * *'
|
?? scheduleConfig?.cron?.schedule
|
||||||
} else if (scheduleConfig?.cron?.schedule) {
|
?? '0 9 * * *'
|
||||||
cronSchedule = scheduleConfig.cron.schedule
|
|
||||||
} else {
|
|
||||||
cronSchedule = '0 9 * * *'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next run based on cron expression
|
// Calculate next run based on cron expression
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
Reference in New Issue
Block a user