import chalk from 'chalk' import fs from 'fs' import path from 'path' import { Account } from '../../interface/Account' import { Config } from '../../interface/Config' import { log } from '../notifications/Logger' interface ValidationError { severity: 'error' | 'warning' category: string message: string fix?: string docsLink?: string blocking?: boolean // If true, prevents bot startup } export class StartupValidator { private errors: ValidationError[] = [] private warnings: ValidationError[] = [] /** * Run all validation checks before starting the bot. * Throws ValidationError if critical (blocking) errors are found. * Displays errors and warnings to help users fix configuration issues. */ async validate(config: Config, accounts: Account[]): Promise { log('main', 'STARTUP', 'Running configuration validation...') // Run all validation checks this.validateAccounts(accounts) this.validateConfig(config) this.validateEnvironment() this.validateFileSystem(config) this.validateBrowserSettings(config) this.validateNetworkSettings(config) this.validateWorkerSettings(config) this.validateExecutionSettings(config) this.validateSearchSettings(config) this.validateHumanizationSettings(config) this.validateSecuritySettings(config) // Display results (await to respect the delay) await this.displayResults() // Check for blocking errors const blockingErrors = this.errors.filter(e => e.blocking === true) if (blockingErrors.length > 0) { const errorMsg = `Validation failed with ${blockingErrors.length} critical error(s). Fix configuration before proceeding.` log('main', 'VALIDATION', errorMsg, 'error') throw new Error(errorMsg) } // Non-blocking errors and warnings allow execution to continue return true } private validateAccounts(accounts: Account[]): void { if (!accounts || accounts.length === 0) { this.addError( 'accounts', 'No accounts found in accounts.json', 'Add at least one account to src/accounts.json or src/accounts.jsonc', 'docs/accounts.md', true // blocking: no accounts = nothing to run ) return } accounts.forEach((account, index) => { const prefix = `Account ${index + 1} (${account.email || 'unknown'})` // Required: email if (!account.email || typeof account.email !== 'string') { this.addError( 'accounts', `${prefix}: Missing or invalid email address`, 'Add a valid email address in the "email" field', undefined, true // blocking: email is required ) } else if (!/@/.test(account.email)) { this.addError( 'accounts', `${prefix}: Email format is invalid`, 'Email must contain @ symbol (e.g., user@example.com)', undefined, true // blocking: invalid email = cannot login ) } // Required: password if (!account.password || typeof account.password !== 'string') { this.addError( 'accounts', `${prefix}: Missing or invalid password`, 'Add your Microsoft account password in the "password" field', undefined, true // blocking: password is required ) } else if (account.password.length < 4) { this.addWarning( 'accounts', `${prefix}: Password seems too short (${account.password.length} characters)`, 'Verify this is your correct Microsoft account password' ) } // Simplified: only validate recovery email if provided if (account.recoveryEmail && typeof account.recoveryEmail === 'string' && account.recoveryEmail.trim() !== '') { if (!/@/.test(account.recoveryEmail)) { this.addError( 'accounts', `${prefix}: Recovery email format is invalid`, 'Recovery email must be a valid email address (e.g., backup@gmail.com)' ) } } else { this.addWarning( 'accounts', `${prefix}: No recovery email configured`, 'Recovery email is optional but recommended for security challenge verification', 'docs/accounts.md' ) } // Optional but recommended: TOTP if (!account.totp || account.totp.trim() === '') { this.addWarning( 'accounts', `${prefix}: No TOTP (2FA) secret configured`, 'Highly recommended: Set up 2FA and add your TOTP secret for automated login', 'docs/accounts.md' ) } else { const cleaned = account.totp.replace(/\s+/g, '') if (cleaned.length < 16) { this.addWarning( 'accounts', `${prefix}: TOTP secret seems too short (${cleaned.length} chars)`, 'Verify you copied the complete Base32 secret from Microsoft Authenticator setup' ) } // Check if it's Base32 (A-Z, 2-7) if (!/^[A-Z2-7\s]+$/i.test(account.totp)) { this.addWarning( 'accounts', `${prefix}: TOTP secret contains invalid characters`, 'TOTP secrets should only contain letters A-Z and numbers 2-7 (Base32 format)' ) } } // Proxy validation if (account.proxy) { const hasProxyUrl = account.proxy.url && account.proxy.url.trim() !== '' const proxyEnabled = account.proxy.proxyAxios === true if (proxyEnabled && !hasProxyUrl) { this.addError( 'accounts', `${prefix}: proxyAxios is true but proxy URL is empty`, 'Set proxyAxios to false if not using a proxy, or provide valid proxy URL/port', undefined, true // blocking ) } if (hasProxyUrl) { if (!account.proxy.port || account.proxy.port <= 0) { this.addError( 'accounts', `${prefix}: Proxy URL provided but port is missing or invalid`, 'Add a valid proxy port number (e.g., 8080, 3128)' ) } } } }) } 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( 'config', 'FORCE_HEADLESS=1 but config.browser.headless is false', 'Docker environment forces headless mode. Your config setting will be overridden.' ) } // Parallel mode warning if (config.parallel === true) { this.addWarning( 'config', 'Parallel mode enabled (desktop + mobile run simultaneously)', 'This uses more resources. Disable if you experience crashes or timeouts.', 'docs/config.md' ) } // Clusters validation if (config.clusters > 1) { this.addWarning( 'config', `Clusters set to ${config.clusters} - accounts will run in parallel`, 'Ensure your system has enough resources (RAM, CPU) for concurrent execution' ) } // Global timeout validation const timeout = typeof config.globalTimeout === 'string' ? config.globalTimeout : `${config.globalTimeout}ms` if (timeout === '0' || timeout === '0ms' || timeout === '0s') { this.addError( 'config', 'Global timeout is set to 0', 'Set a reasonable timeout value (e.g., "30s", "60s") to prevent infinite hangs', undefined, true // blocking: 0 timeout = infinite hangs guaranteed ) } // Job state validation if (config.jobState?.enabled === false) { this.addWarning( 'config', 'Job state tracking is disabled', 'The bot will not save progress. If interrupted, all tasks will restart from scratch.', 'docs/jobstate.md' ) } // Risk management validation if (config.riskManagement?.enabled === true) { // If risk management is enabled, notify the user to ensure policies are configured. // This avoids an empty-block lint/compile error and provides actionable guidance. this.addWarning( 'riskManagement', 'Risk management is enabled but no specific policies were validated here', 'Review and configure riskManagement settings (throttles, maxRestarts, detection thresholds)', 'docs/config.md' ) } // Search delays validation const minDelay = typeof config.searchSettings.searchDelay.min === 'string' ? config.searchSettings.searchDelay.min : `${config.searchSettings.searchDelay.min}ms` if (minDelay === '0' || minDelay === '0ms' || minDelay === '0s') { this.addWarning( 'config', 'Search delay minimum is 0 - this may look suspicious', 'Consider setting a minimum delay (e.g., "1s", "2s") for more natural behavior' ) } } private validateEnvironment(): void { // Node.js version check const nodeVersion = process.version const major = parseInt(nodeVersion.split('.')[0]?.replace('v', '') || '0', 10) if (major < 18) { this.addError( 'environment', `Node.js version ${nodeVersion} is too old`, 'Install Node.js 18 or newer. Visit https://nodejs.org/', 'docs/getting-started.md' ) } else if (major < 20) { this.addWarning( 'environment', `Node.js version ${nodeVersion} is outdated`, 'Consider upgrading to Node.js 20+ for better performance and security' ) } // Docker-specific checks if (process.env.FORCE_HEADLESS === '1') { this.addWarning( 'environment', 'Running in Docker/containerized environment', 'Make sure volumes are correctly mounted for sessions persistence' ) } // Time sync info for TOTP users (informational, not a problem) if (process.platform === 'linux') { // This is just informational - not displayed as warning log('main', 'VALIDATION', '💡 Linux detected: Ensure system time is synchronized for TOTP') log('main', 'VALIDATION', ' Suggestion: Run: sudo timedatectl set-ntp true (required for TOTP to work correctly)') } } private validateFileSystem(config: Config): void { // Check if sessions directory exists or can be created const sessionPath = path.isAbsolute(config.sessionPath) ? config.sessionPath : path.join(process.cwd(), config.sessionPath) if (!fs.existsSync(sessionPath)) { try { fs.mkdirSync(sessionPath, { recursive: true }) this.addWarning( 'filesystem', `Created missing sessions directory: ${sessionPath}`, 'Session data will be stored here' ) } catch (error) { this.addError( 'filesystem', `Cannot create sessions directory: ${sessionPath}`, `Check file permissions. Error: ${error instanceof Error ? error.message : String(error)}` ) } } // Check job-state directory if enabled if (config.jobState?.enabled !== false) { const jobStateDir = config.jobState?.dir ? config.jobState.dir : path.join(sessionPath, 'job-state') if (!fs.existsSync(jobStateDir)) { try { fs.mkdirSync(jobStateDir, { recursive: true }) } catch (error) { this.addWarning( 'filesystem', `Cannot create job-state directory: ${jobStateDir}`, 'Job state tracking may fail. Check file permissions.' ) } } } } private validateBrowserSettings(config: Config): void { // Headless validation - only warn in Docker/containerized environments if (!config.browser?.headless && process.env.FORCE_HEADLESS === '1') { this.addWarning( 'browser', 'FORCE_HEADLESS=1 but config.browser.headless is false', 'Docker environment forces headless mode. Your config setting will be overridden.', 'docs/docker.md' ) } // Fingerprinting validation if (config.saveFingerprint?.desktop === false && config.saveFingerprint?.mobile === false) { this.addWarning( 'browser', 'Fingerprint saving is completely disabled', 'Each run will generate new fingerprints, which may look suspicious' ) } } private validateNetworkSettings(config: Config): void { // Webhook validation if (config.webhook?.enabled === true) { if (!config.webhook.url || config.webhook.url.trim() === '') { this.addError( 'network', 'Webhook enabled but URL is missing', 'Add webhook URL or set webhook.enabled=false', 'docs/config.md', true // blocking: enabled but no URL = will crash ) } else if (!config.webhook.url.startsWith('http')) { this.addError( 'network', `Invalid webhook URL: ${config.webhook.url}`, 'Webhook URL must start with http:// or https://', undefined, true // blocking: invalid URL = will crash ) } } // Conclusion webhook validation if (config.conclusionWebhook?.enabled === true) { if (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '') { this.addError( 'network', 'Conclusion webhook enabled but URL is missing', 'Add conclusion webhook URL or disable it', undefined, true // blocking: enabled but no URL = will crash ) } } // NTFY validation if (config.ntfy?.enabled === true) { if (!config.ntfy.url || config.ntfy.url.trim() === '') { this.addError( 'network', 'NTFY enabled but URL is missing', 'Add NTFY server URL or set ntfy.enabled=false', 'docs/ntfy.md', true // blocking: enabled but no URL = will crash ) } if (!config.ntfy.topic || config.ntfy.topic.trim() === '') { this.addError( 'network', 'NTFY enabled but topic is missing', 'Add NTFY topic name', 'docs/ntfy.md', true // blocking: enabled but no topic = will crash ) } } } private validateWorkerSettings(config: Config): void { const workers = config.workers // Check if at least one worker is enabled const anyEnabled = workers.doDailySet || workers.doMorePromotions || workers.doPunchCards || workers.doDesktopSearch || workers.doMobileSearch || workers.doDailyCheckIn || workers.doReadToEarn if (!anyEnabled) { this.addWarning( 'workers', 'All workers are disabled - bot will do nothing', 'Enable at least one worker task (doDailySet, doDesktopSearch, etc.)', 'docs/config.md' ) } // Mobile + desktop search check if (!workers.doDesktopSearch && !workers.doMobileSearch) { this.addWarning( 'workers', 'Both desktop and mobile searches are disabled', 'Enable at least one search type to earn search points' ) } // Bundle validation if (workers.bundleDailySetWithSearch === true && !workers.doDesktopSearch) { this.addWarning( 'workers', 'bundleDailySetWithSearch is enabled but doDesktopSearch is disabled', 'Desktop search will not run after Daily Set. Enable doDesktopSearch or disable bundling.' ) } } private validateExecutionSettings(config: Config): void { // Validate passesPerRun const passes = config.passesPerRun ?? 1 if (passes < 1) { this.addError( 'execution', 'passesPerRun must be at least 1', 'Set passesPerRun to 1 or higher in config.jsonc', undefined, true ) } if (passes > 5) { this.addWarning( 'execution', `passesPerRun is set to ${passes} (very high)`, 'Running multiple passes per day may trigger Microsoft detection. Recommended: 1-2 passes max', 'docs/config-reference.md' ) } if (passes > 1) { // This is intentional behavior confirmation, not a warning log('main', 'VALIDATION', `✓ [OK] passesPerRun = ${passes}: Job-state skip is disabled (intentional)`) log('main', 'VALIDATION', ' Suggestion: All accounts will run on every pass, even if already completed. This is intentional for multiple passes.') log('main', 'VALIDATION', ' Docs: docs/jobstate.md') } // Validate clusters if (config.clusters < 1) { this.addError( 'execution', 'clusters must be at least 1', 'Set clusters to 1 or higher in config.jsonc', undefined, true ) } if (config.clusters > 10) { this.addWarning( 'execution', `clusters is set to ${config.clusters} (very high)`, 'Too many clusters may cause resource exhaustion. Recommended: 1-4 clusters' ) } } private validateSearchSettings(config: Config): void { const search = config.searchSettings // Retry validation if (search.retryMobileSearchAmount < 0) { this.addWarning( 'search', 'retryMobileSearchAmount is negative', 'Set to 0 or positive number (recommended: 2-3)' ) } if (search.retryMobileSearchAmount > 10) { this.addWarning( 'search', `retryMobileSearchAmount is very high (${search.retryMobileSearchAmount})`, 'High retry count may trigger detection. Recommended: 2-3' ) } // Fallback validation if (search.localFallbackCount !== undefined && search.localFallbackCount < 10) { this.addWarning( 'search', `localFallbackCount is low (${search.localFallbackCount})`, 'Consider at least 15-25 fallback queries for variety' ) } // Query diversity check if (config.queryDiversity?.enabled === false && !config.searchOnBingLocalQueries) { this.addWarning( 'search', 'Query diversity disabled and local queries disabled', 'Bot will only use Google Trends. Enable one query source for better variety.', 'docs/config.md' ) } } private validateHumanizationSettings(config: Config): void { const human = config.humanization if (!human || human.enabled === false) { this.addWarning( 'humanization', 'Humanization is completely disabled', 'This increases detection risk. Consider enabling for safer automation.', 'docs/config.md' ) return } // Gesture probabilities if (human.gestureMoveProb !== undefined) { if (human.gestureMoveProb < 0 || human.gestureMoveProb > 1) { this.addError( 'humanization', `gestureMoveProb must be between 0 and 1 (got ${human.gestureMoveProb})`, 'Set a probability value between 0.0 and 1.0' ) } if (human.gestureMoveProb === 0) { this.addWarning( 'humanization', 'Mouse gestures disabled (gestureMoveProb=0)', 'This may look robotic. Consider 0.3-0.7 for natural behavior.' ) } } if (human.gestureScrollProb !== undefined) { if (human.gestureScrollProb < 0 || human.gestureScrollProb > 1) { this.addError( 'humanization', `gestureScrollProb must be between 0 and 1 (got ${human.gestureScrollProb})`, 'Set a probability value between 0.0 and 1.0' ) } } // Action delays if (human.actionDelay) { const minMs = typeof human.actionDelay.min === 'string' ? parseInt(human.actionDelay.min, 10) : human.actionDelay.min const maxMs = typeof human.actionDelay.max === 'string' ? parseInt(human.actionDelay.max, 10) : human.actionDelay.max if (minMs > maxMs) { this.addError( 'humanization', 'actionDelay min is greater than max', `Fix: min=${minMs} should be <= max=${maxMs}` ) } } // Random off days if (human.randomOffDaysPerWeek !== undefined) { if (human.randomOffDaysPerWeek < 0 || human.randomOffDaysPerWeek > 7) { this.addError( 'humanization', `randomOffDaysPerWeek must be 0-7 (got ${human.randomOffDaysPerWeek})`, 'Set to a value between 0 (no off days) and 7 (always off)' ) } } // Allowed windows validation if (human.allowedWindows && Array.isArray(human.allowedWindows)) { human.allowedWindows.forEach((window, idx) => { if (typeof window !== 'string') { this.addError( 'humanization', `allowedWindows[${idx}] is not a string`, 'Format: "HH:mm-HH:mm" (e.g., "09:00-17:00")' ) } else if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) { this.addWarning( 'humanization', `allowedWindows[${idx}] format may be invalid: "${window}"`, 'Expected format: "HH:mm-HH:mm" (24-hour, e.g., "09:00-17:00")' ) } }) } } private validateSecuritySettings(config: Config): void { // Check logging redaction const logging = config.logging as { redactEmails?: boolean } | undefined if (logging && logging.redactEmails === false) { this.addWarning( 'security', 'Email redaction is disabled in logs', 'Enable redactEmails=true if you share logs publicly', 'docs/security.md' ) } // Proxy exposure check if (config.proxy?.proxyGoogleTrends === false && config.proxy?.proxyBingTerms === false) { this.addWarning( 'security', 'All external API calls will use your real IP', 'Consider enabling proxy for Google Trends or Bing Terms to mask your IP' ) } // Crash recovery if (config.crashRecovery?.autoRestart === true) { const maxRestarts = config.crashRecovery.maxRestarts ?? 2 if (maxRestarts > 5) { this.addWarning( 'security', `Crash recovery maxRestarts is high (${maxRestarts})`, 'Excessive restarts on errors may trigger rate limits or detection' ) } } } private addError(category: string, message: string, fix?: string, docsLink?: string, blocking = false): void { this.errors.push({ severity: 'error', category, message, fix, docsLink, blocking }) } private addWarning(category: string, message: string, fix?: string, docsLink?: string): void { this.warnings.push({ severity: 'warning', category, message, fix, docsLink, blocking: false }) } private async displayResults(): Promise { if (this.errors.length > 0) { log('main', 'VALIDATION', chalk.red('❌ VALIDATION ERRORS FOUND:'), 'error') this.errors.forEach((err, index) => { log('main', 'VALIDATION', chalk.red(`${index + 1}. [${err.category.toUpperCase()}] ${err.message}`), 'error') if (err.fix) { log('main', 'VALIDATION', chalk.yellow(` Fix: ${err.fix}`), 'warn') } if (err.docsLink) { log('main', 'VALIDATION', ` Docs: ${err.docsLink}`) } }) } if (this.warnings.length > 0) { log('main', 'VALIDATION', chalk.yellow('⚠️ WARNINGS:'), 'warn') this.warnings.forEach((warn, index) => { log('main', 'VALIDATION', chalk.yellow(`${index + 1}. [${warn.category.toUpperCase()}] ${warn.message}`), 'warn') if (warn.fix) { log('main', 'VALIDATION', ` Suggestion: ${warn.fix}`) } if (warn.docsLink) { log('main', 'VALIDATION', ` Docs: ${warn.docsLink}`) } }) } if (this.errors.length === 0 && this.warnings.length === 0) { log('main', 'VALIDATION', chalk.green('✅ All validation checks passed!')) } else { const errorLabel = this.errors.length === 1 ? 'error' : 'errors' const warningLabel = this.warnings.length === 1 ? 'warning' : 'warnings' log('main', 'VALIDATION', `[${this.errors.length > 0 ? 'ERROR' : 'OK'}] Found: ${this.errors.length} ${errorLabel} | ${this.warnings.length} ${warningLabel}`) if (this.errors.length > 0) { log('main', 'VALIDATION', 'Bot will continue, but issues may cause failures', 'warn') log('main', 'VALIDATION', 'Full documentation: docs/index.md') await new Promise(resolve => setTimeout(resolve, 3000)) } else if (this.warnings.length > 0) { log('main', 'VALIDATION', 'Warnings detected - review recommended', 'warn') await new Promise(resolve => setTimeout(resolve, 2000)) } } } }