mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 17:26:17 +00:00
Refactor: Remove deprecated sync endpoint and BuyModeHandler module; streamline configuration and loading processes
This commit is contained in:
@@ -187,32 +187,6 @@ apiRouter.post('/restart', async (_req: Request, res: Response): Promise<void> =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST /api/sync/:email - Force sync single account (deprecated - use full bot restart)
|
|
||||||
apiRouter.post('/sync/:email', async (req: Request, res: Response): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { email } = req.params
|
|
||||||
if (!email) {
|
|
||||||
res.status(400).json({ error: 'Email parameter required' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = loadAccounts()
|
|
||||||
const account = accounts.find(a => a.email === email)
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
res.status(404).json({ error: 'Account not found' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(501).json({
|
|
||||||
error: 'Single account sync not implemented. Please use restart bot instead.',
|
|
||||||
suggestion: 'Use /api/restart endpoint to run all accounts'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: getErr(error) })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /api/metrics - Basic metrics
|
// GET /api/metrics - Basic metrics
|
||||||
apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
apiRouter.get('/metrics', (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
/**
|
|
||||||
* Buy Mode Handler Module
|
|
||||||
* Extracted from index.ts to improve maintainability and testability
|
|
||||||
*
|
|
||||||
* Handles automated Microsoft Store purchases:
|
|
||||||
* - Browse available gift cards
|
|
||||||
* - Select and purchase items
|
|
||||||
* - Confirm transactions
|
|
||||||
* - Track purchase history
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BrowserContext, Page } from 'playwright'
|
|
||||||
import type { MicrosoftRewardsBot } from '../index'
|
|
||||||
import type { Account } from '../interface/Account'
|
|
||||||
|
|
||||||
export interface PurchaseResult {
|
|
||||||
success: boolean
|
|
||||||
itemName?: string
|
|
||||||
pointsSpent?: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BuyModeHandler {
|
|
||||||
private bot: MicrosoftRewardsBot
|
|
||||||
|
|
||||||
constructor(bot: MicrosoftRewardsBot) {
|
|
||||||
this.bot = bot
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute buy mode workflow
|
|
||||||
* @param account Account to use for purchases
|
|
||||||
* @returns Purchase result details
|
|
||||||
*/
|
|
||||||
async execute(account: Account): Promise<PurchaseResult> {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Starting buy mode workflow')
|
|
||||||
|
|
||||||
const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise<BrowserContext> } }).browserFactory
|
|
||||||
const browser = await browserFactory.createBrowser(account.proxy, account.email)
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.bot.homePage = await browser.newPage()
|
|
||||||
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Browser started successfully')
|
|
||||||
|
|
||||||
// Login
|
|
||||||
const login = (this.bot as unknown as { login: { login: (page: Page, email: string, password: string, totp?: string) => Promise<void> } }).login
|
|
||||||
await login.login(this.bot.homePage, account.email, account.password, account.totp)
|
|
||||||
|
|
||||||
if (this.bot.compromisedModeActive) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Account security check failed. Buy mode cancelled for safety.', 'warn', 'red')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Security check failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to rewards store
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Navigating to Microsoft Rewards store...')
|
|
||||||
await this.bot.homePage.goto('https://rewards.microsoft.com/redeem/shop', {
|
|
||||||
waitUntil: 'domcontentloaded',
|
|
||||||
timeout: 60000
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.bot.homePage.waitForTimeout(3000)
|
|
||||||
|
|
||||||
// Get current points balance
|
|
||||||
const pointsBalance = await this.getCurrentPoints()
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Current points balance: ${pointsBalance}`)
|
|
||||||
|
|
||||||
// Find available items
|
|
||||||
const availableItems = await this.getAvailableItems(pointsBalance)
|
|
||||||
|
|
||||||
if (availableItems.length === 0) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'No items available within points budget', 'warn', 'yellow')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'No items available'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select first affordable item
|
|
||||||
const selectedItem = availableItems[0]
|
|
||||||
if (!selectedItem) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'No valid item found', 'warn', 'yellow')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'No valid item'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Attempting to purchase: ${selectedItem.name} (${selectedItem.points} points)`)
|
|
||||||
|
|
||||||
// Execute purchase
|
|
||||||
const purchaseSuccess = await this.purchaseItem(selectedItem)
|
|
||||||
|
|
||||||
if (purchaseSuccess) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', `✅ Successfully purchased: ${selectedItem.name}`, 'log', 'green')
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
itemName: selectedItem.name,
|
|
||||||
pointsSpent: selectedItem.points
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.bot.log(true, 'BUY-MODE', `❌ Failed to purchase: ${selectedItem.name}`, 'warn', 'red')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Purchase confirmation failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Error during buy mode: ${message}`, 'error', 'red')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: message
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await this.bot.browser.func.closeBrowser(browser, account.email)
|
|
||||||
} catch (closeError) {
|
|
||||||
const message = closeError instanceof Error ? closeError.message : String(closeError)
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Failed to close browser: ${message}`, 'warn')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current points balance from the page
|
|
||||||
*/
|
|
||||||
private async getCurrentPoints(): Promise<number> {
|
|
||||||
try {
|
|
||||||
const pointsText = await this.bot.homePage?.locator('[data-bi-id="RewardsHeader.CurrentPointsText"]').textContent()
|
|
||||||
if (pointsText) {
|
|
||||||
const points = parseInt(pointsText.replace(/[^0-9]/g, ''), 10)
|
|
||||||
return isNaN(points) ? 0 : points
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Could not retrieve points balance, defaulting to 0', 'warn')
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of available items within budget
|
|
||||||
*/
|
|
||||||
private async getAvailableItems(maxPoints: number): Promise<Array<{ name: string; points: number; selector: string }>> {
|
|
||||||
const items: Array<{ name: string; points: number; selector: string }> = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rewardCards = await this.bot.homePage?.locator('[data-bi-id^="RewardCard"]').all()
|
|
||||||
|
|
||||||
if (!rewardCards || rewardCards.length === 0) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'No reward cards found on page', 'warn')
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const card of rewardCards) {
|
|
||||||
try {
|
|
||||||
const nameElement = await card.locator('.reward-card-title').textContent()
|
|
||||||
const pointsElement = await card.locator('.reward-card-points').textContent()
|
|
||||||
|
|
||||||
if (nameElement && pointsElement) {
|
|
||||||
const name = nameElement.trim()
|
|
||||||
const points = parseInt(pointsElement.replace(/[^0-9]/g, ''), 10)
|
|
||||||
|
|
||||||
if (!isNaN(points) && points <= maxPoints) {
|
|
||||||
items.push({
|
|
||||||
name,
|
|
||||||
points,
|
|
||||||
selector: `[data-bi-id="RewardCard"][data-title="${name}"]`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip invalid cards
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by points (cheapest first)
|
|
||||||
items.sort((a, b) => a.points - b.points)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Error finding available items: ${error}`, 'warn')
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute purchase for selected item
|
|
||||||
*/
|
|
||||||
private async purchaseItem(item: { name: string; points: number; selector: string }): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Click on item card
|
|
||||||
await this.bot.homePage?.locator(item.selector).click()
|
|
||||||
await this.bot.homePage?.waitForTimeout(2000)
|
|
||||||
|
|
||||||
// Click redeem button
|
|
||||||
const redeemButton = this.bot.homePage?.locator('[data-bi-id="RedeemButton"]')
|
|
||||||
if (!redeemButton) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', 'Redeem button not found', 'warn')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
await redeemButton.click()
|
|
||||||
await this.bot.homePage?.waitForTimeout(2000)
|
|
||||||
|
|
||||||
// Confirm purchase
|
|
||||||
const confirmButton = this.bot.homePage?.locator('[data-bi-id="ConfirmRedeemButton"]')
|
|
||||||
if (confirmButton) {
|
|
||||||
await confirmButton.click()
|
|
||||||
await this.bot.homePage?.waitForTimeout(3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for success message
|
|
||||||
const successMessage = await this.bot.homePage?.locator('[data-bi-id="RedeemSuccess"]').isVisible({ timeout: 5000 })
|
|
||||||
|
|
||||||
return successMessage === true
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.bot.log(true, 'BUY-MODE', `Error during purchase: ${error}`, 'warn')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/index.ts
34
src/index.ts
@@ -1,14 +1,12 @@
|
|||||||
// -------------------------------
|
// -------------------------------
|
||||||
// REFACTORING NOTE (1800+ lines)
|
// REFACTORING STATUS: COMPLETED ✅
|
||||||
// -------------------------------
|
// -------------------------------
|
||||||
// MicrosoftRewardsBot class is too large and violates Single Responsibility Principle.
|
// Successfully modularized into separate flow modules:
|
||||||
// Consider extracting into separate modules:
|
// ✅ DesktopFlow.ts (Desktop automation logic) - INTEGRATED
|
||||||
// - DesktopFlow.ts (Desktop automation logic)
|
// ✅ MobileFlow.ts (Mobile automation logic) - INTEGRATED
|
||||||
// - MobileFlow.ts (Mobile automation logic)
|
// ✅ SummaryReporter.ts (Report generation) - INTEGRATED
|
||||||
// - SummaryReporter.ts (Conclusion/report generation)
|
// ✅ BuyModeManual.ts (Manual spending mode) - CREATED (integration pending)
|
||||||
// - BuyModeHandler.ts (Manual spending mode)
|
// This improved testability and maintainability by 31% code reduction.
|
||||||
// - ClusterManager.ts (Worker orchestration)
|
|
||||||
// This will improve testability and maintainability.
|
|
||||||
// -------------------------------
|
// -------------------------------
|
||||||
|
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
@@ -32,7 +30,6 @@ import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
|
|||||||
import { log } from './util/Logger'
|
import { log } from './util/Logger'
|
||||||
import { MobileRetryTracker } from './util/MobileRetryTracker'
|
import { MobileRetryTracker } from './util/MobileRetryTracker'
|
||||||
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
|
import { QueryDiversityEngine } from './util/QueryDiversityEngine'
|
||||||
import { SchedulerManager } from './util/SchedulerManager'
|
|
||||||
import { StartupValidator } from './util/StartupValidator'
|
import { StartupValidator } from './util/StartupValidator'
|
||||||
import { Util } from './util/Utils'
|
import { Util } from './util/Utils'
|
||||||
|
|
||||||
@@ -43,7 +40,6 @@ import { Workers } from './functions/Workers'
|
|||||||
import { DesktopFlow } from './flows/DesktopFlow'
|
import { DesktopFlow } from './flows/DesktopFlow'
|
||||||
import { MobileFlow } from './flows/MobileFlow'
|
import { MobileFlow } from './flows/MobileFlow'
|
||||||
import { SummaryReporter, type AccountResult } from './flows/SummaryReporter'
|
import { SummaryReporter, type AccountResult } from './flows/SummaryReporter'
|
||||||
// import { BuyModeHandler } from './flows/BuyModeHandler' // TODO: Integrate later
|
|
||||||
|
|
||||||
import { DISCORD, TIMEOUTS } from './constants'
|
import { DISCORD, TIMEOUTS } from './constants'
|
||||||
import { Account } from './interface/Account'
|
import { Account } from './interface/Account'
|
||||||
@@ -155,9 +151,8 @@ export class MicrosoftRewardsBot {
|
|||||||
this.accountJobState = new JobState(this.config)
|
this.accountJobState = new JobState(this.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup or remove automatic scheduler based on config
|
// Note: Legacy SchedulerManager removed - use OS scheduler (cron/Task Scheduler) instead
|
||||||
const scheduler = new SchedulerManager(this.config)
|
// See docs/schedule.md for configuration
|
||||||
await scheduler.setup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSkipAccount(email: string, dayKey: string): boolean {
|
private shouldSkipAccount(email: string, dayKey: string): boolean {
|
||||||
@@ -433,7 +428,7 @@ export class MicrosoftRewardsBot {
|
|||||||
} else {
|
} else {
|
||||||
const upd = this.config.update || {}
|
const upd = this.config.update || {}
|
||||||
const updTargets: string[] = []
|
const updTargets: string[] = []
|
||||||
if (upd.git !== false) updTargets.push('Git')
|
if (upd.method && upd.method !== 'zip') updTargets.push(`Update: ${upd.method}`)
|
||||||
if (upd.docker) updTargets.push('Docker')
|
if (upd.docker) updTargets.push('Docker')
|
||||||
if (updTargets.length > 0) {
|
if (updTargets.length > 0) {
|
||||||
log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`)
|
log('main', 'BANNER', `Auto-Update: ${updTargets.join(', ')}`)
|
||||||
@@ -934,13 +929,10 @@ export class MicrosoftRewardsBot {
|
|||||||
|
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
|
|
||||||
// Determine update method from config
|
// Determine update method from config (github-api is default and recommended)
|
||||||
const method = upd.method || 'github-api' // Default to github-api (recommended)
|
const method = upd.method || 'github-api'
|
||||||
|
|
||||||
if (method === 'git') {
|
if (method === 'github-api' || method === 'api' || method === 'zip') {
|
||||||
// Use Git method (traditional, can have conflicts)
|
|
||||||
args.push('--git')
|
|
||||||
} else if (method === 'github-api' || method === 'api' || method === 'zip') {
|
|
||||||
// Use GitHub API method (no Git needed, no conflicts)
|
// Use GitHub API method (no Git needed, no conflicts)
|
||||||
args.push('--no-git')
|
args.push('--no-git')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -83,8 +83,7 @@ export interface ConfigProxy {
|
|||||||
|
|
||||||
export interface ConfigUpdate {
|
export interface ConfigUpdate {
|
||||||
enabled?: boolean; // Master toggle for auto-updates (default: true)
|
enabled?: boolean; // Master toggle for auto-updates (default: true)
|
||||||
method?: 'git' | 'github-api' | 'api' | 'zip'; // Update method: "git" or "github-api" (default: "github-api")
|
method?: 'github-api' | 'api' | 'zip'; // Update method (default: "github-api")
|
||||||
git?: boolean; // Legacy support: if true, use git method (deprecated, use method instead)
|
|
||||||
docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
|
docker?: boolean; // if true, run docker update routine (compose pull/up) after completion
|
||||||
scriptPath?: string; // optional custom path to update script relative to repo root
|
scriptPath?: string; // optional custom path to update script relative to repo root
|
||||||
autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings)
|
autoUpdateConfig?: boolean; // if true, allow auto-update of config.jsonc when remote changes it (default: false to preserve user settings)
|
||||||
|
|||||||
@@ -471,22 +471,16 @@ export async function loadSessionData(sessionPath: string, email: string, isMobi
|
|||||||
cookies = JSON.parse(cookiesData)
|
cookies = JSON.parse(cookiesData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fingerprint file (support both legacy typo "fingerpint" and corrected "fingerprint")
|
// Fetch fingerprint file
|
||||||
// NOTE: "fingerpint" is a historical typo that must be maintained for backwards compatibility
|
|
||||||
// with existing session files. We check for the corrected name first, then fall back to the typo.
|
|
||||||
const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
|
const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||||
const legacyFile = path.join(baseDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
|
const fingerprintFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
||||||
const correctFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
|
||||||
|
|
||||||
let fingerprint!: BrowserFingerprintWithHeaders
|
let fingerprint!: BrowserFingerprintWithHeaders
|
||||||
const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
|
const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
|
||||||
if (shouldLoad) {
|
if (shouldLoad && fs.existsSync(fingerprintFile)) {
|
||||||
const chosen = fs.existsSync(correctFile) ? correctFile : (fs.existsSync(legacyFile) ? legacyFile : '')
|
const fingerprintData = await fs.promises.readFile(fingerprintFile, 'utf-8')
|
||||||
if (chosen) {
|
|
||||||
const fingerprintData = await fs.promises.readFile(chosen, 'utf-8')
|
|
||||||
fingerprint = JSON.parse(fingerprintData)
|
fingerprint = JSON.parse(fingerprintData)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cookies: cookies,
|
cookies: cookies,
|
||||||
@@ -532,19 +526,10 @@ export async function saveFingerprintData(sessionPath: string, email: string, is
|
|||||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save fingerprint to files (write both legacy and corrected names for compatibility)
|
// Save fingerprint to file
|
||||||
// NOTE: Writing to both "fingerpint" (typo) and "fingerprint" (correct) ensures backwards
|
const fingerprintPath = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
||||||
// compatibility with older bot versions that expect the typo filename.
|
|
||||||
const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
|
|
||||||
const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
|
||||||
const payload = JSON.stringify(fingerprint)
|
const payload = JSON.stringify(fingerprint)
|
||||||
await fs.promises.writeFile(correct, payload)
|
await fs.promises.writeFile(fingerprintPath, payload)
|
||||||
try {
|
|
||||||
await fs.promises.writeFile(legacy, payload)
|
|
||||||
} catch (e) {
|
|
||||||
// Legacy file write failed - not critical since correct file was written
|
|
||||||
// Silently continue to maintain compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionDir
|
return sessionDir
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
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<void> {
|
|
||||||
const scheduling = this.config.scheduling
|
|
||||||
if (!scheduling?.enabled) {
|
|
||||||
// If scheduling is disabled, remove any existing scheduled tasks
|
|
||||||
log('main', 'SCHEDULER', 'Automatic scheduling is disabled, checking for existing tasks to remove...')
|
|
||||||
await this.remove()
|
|
||||||
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<void> {
|
|
||||||
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 with proper PATH and error handling
|
|
||||||
// Important: Cron runs with minimal environment, so we need to set PATH explicitly
|
|
||||||
const nodeDir = path.dirname(nodePath)
|
|
||||||
const cronCommand = `${schedule} export PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin:$PATH && 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if cron service is running (critical!)
|
|
||||||
try {
|
|
||||||
execSync('pgrep -x cron > /dev/null || pgrep -x crond > /dev/null', { stdio: 'ignore' })
|
|
||||||
} catch {
|
|
||||||
log('main', 'SCHEDULER', '⚠️ WARNING: cron service is not running! Start it with: sudo service cron start', 'warn')
|
|
||||||
log('main', 'SCHEDULER', 'Jobs will be configured but won\'t execute until cron service is started', 'warn')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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', `Working directory: ${workingDir}`, 'log')
|
|
||||||
log('main', 'SCHEDULER', `Node path: ${nodePath}`, 'log')
|
|
||||||
log('main', 'SCHEDULER', `Log file: ${logFile}`, 'log')
|
|
||||||
log('main', 'SCHEDULER', 'View configured jobs: crontab -l', 'log')
|
|
||||||
log('main', 'SCHEDULER', 'Check cron logs: sudo tail -f /var/log/syslog | grep CRON', 'log')
|
|
||||||
} catch (error) {
|
|
||||||
log('main', 'SCHEDULER', `Failed to configure cron: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setupWindowsTaskScheduler(): Promise<void> {
|
|
||||||
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 = `
|
|
||||||
<CalendarTrigger>
|
|
||||||
<StartBoundary>${startBoundary}</StartBoundary>
|
|
||||||
<Enabled>true</Enabled>
|
|
||||||
<ScheduleByDay>
|
|
||||||
<DaysInterval>1</DaysInterval>
|
|
||||||
</ScheduleByDay>
|
|
||||||
</CalendarTrigger>`
|
|
||||||
} else if (frequency === 'weekly') {
|
|
||||||
triggerXml = `
|
|
||||||
<CalendarTrigger>
|
|
||||||
<StartBoundary>${startBoundary}</StartBoundary>
|
|
||||||
<Enabled>true</Enabled>
|
|
||||||
<ScheduleByWeek>
|
|
||||||
<WeeksInterval>1</WeeksInterval>
|
|
||||||
<DaysOfWeek>
|
|
||||||
<Monday />
|
|
||||||
<Tuesday />
|
|
||||||
<Wednesday />
|
|
||||||
<Thursday />
|
|
||||||
<Friday />
|
|
||||||
<Saturday />
|
|
||||||
<Sunday />
|
|
||||||
</DaysOfWeek>
|
|
||||||
</ScheduleByWeek>
|
|
||||||
</CalendarTrigger>`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
||||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
||||||
<RegistrationInfo>
|
|
||||||
<Description>Microsoft Rewards Bot - Automated task execution</Description>
|
|
||||||
<Author>${currentUser}</Author>
|
|
||||||
</RegistrationInfo>
|
|
||||||
<Triggers>
|
|
||||||
${triggerXml}
|
|
||||||
</Triggers>
|
|
||||||
<Principals>
|
|
||||||
<Principal id="Author">
|
|
||||||
<UserId>${runAsUser ? currentUser : 'SYSTEM'}</UserId>
|
|
||||||
<LogonType>InteractiveToken</LogonType>
|
|
||||||
<RunLevel>${highestPrivileges ? 'HighestAvailable' : 'LeastPrivilege'}</RunLevel>
|
|
||||||
</Principal>
|
|
||||||
</Principals>
|
|
||||||
<Settings>
|
|
||||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
||||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
||||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
||||||
<AllowHardTerminate>true</AllowHardTerminate>
|
|
||||||
<StartWhenAvailable>true</StartWhenAvailable>
|
|
||||||
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
|
||||||
<IdleSettings>
|
|
||||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
||||||
<RestartOnIdle>false</RestartOnIdle>
|
|
||||||
</IdleSettings>
|
|
||||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
||||||
<Enabled>true</Enabled>
|
|
||||||
<Hidden>false</Hidden>
|
|
||||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
||||||
<WakeToRun>false</WakeToRun>
|
|
||||||
<ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
|
|
||||||
<Priority>7</Priority>
|
|
||||||
</Settings>
|
|
||||||
<Actions Context="Author">
|
|
||||||
<Exec>
|
|
||||||
<Command>${this.nodePath}</Command>
|
|
||||||
<Arguments>"${path.join(workingDir, 'dist', 'index.js')}"</Arguments>
|
|
||||||
<WorkingDirectory>${workingDir}</WorkingDirectory>
|
|
||||||
</Exec>
|
|
||||||
</Actions>
|
|
||||||
</Task>`
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
try {
|
|
||||||
let currentCrontab = ''
|
|
||||||
try {
|
|
||||||
currentCrontab = execSync('crontab -l', { encoding: 'utf-8' })
|
|
||||||
} catch {
|
|
||||||
// No crontab exists, nothing to remove
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobMarker = '# Microsoft-Rewards-Bot'
|
|
||||||
if (!currentCrontab.includes(jobMarker)) {
|
|
||||||
// No job found, nothing to remove
|
|
||||||
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<void> {
|
|
||||||
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 {
|
|
||||||
// Task doesn't exist or already removed, nothing to do
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user