From 5e322af2c0fbb44633479298ca335325db1de683 Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Sat, 8 Nov 2025 18:52:31 +0100 Subject: [PATCH] Fix: Enhance error handling and timeout management across various modules; improve validation and documentation --- src/browser/Browser.ts | 9 ++- src/browser/BrowserFunc.ts | 23 +++++++- src/constants.ts | 6 +- src/flows/DesktopFlow.ts | 9 ++- src/flows/MobileFlow.ts | 9 ++- src/functions/Activities.ts | 17 ++++-- src/functions/Login.ts | 17 ++++-- src/functions/Workers.ts | 19 ++++-- src/index.ts | 115 +++++++++++++++++++++++++++++------- src/util/Load.ts | 46 +++++++++++---- src/util/Logger.ts | 59 ++++++++++++------ src/util/Retry.ts | 20 ++++++- src/util/Utils.ts | 78 +++++++++++++++++++++--- 13 files changed, 341 insertions(+), 86 deletions(-) diff --git a/src/browser/Browser.ts b/src/browser/Browser.ts index f9ccbe4..ea1c105 100644 --- a/src/browser/Browser.ts +++ b/src/browser/Browser.ts @@ -18,9 +18,14 @@ class Browser { if (process.env.AUTO_INSTALL_BROWSERS === '1') { try { const { execSync } = await import('child_process') - execSync('npx playwright install chromium', { stdio: 'ignore' }) + // FIXED: Add timeout to prevent indefinite blocking + this.bot.log(this.bot.isMobile, 'BROWSER', 'Auto-installing Chromium...', 'log') + execSync('npx playwright install chromium', { stdio: 'ignore', timeout: 120000 }) + this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium installed successfully', 'log') } catch (e) { - this.bot.log(this.bot.isMobile, 'BROWSER', `Auto-install failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') + // FIXED: Improved error logging (no longer silent) + const errorMsg = e instanceof Error ? e.message : String(e) + this.bot.log(this.bot.isMobile, 'BROWSER', `Auto-install failed: ${errorMsg}`, 'warn') } } diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 1a8351c..9ff8165 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -182,10 +182,20 @@ export default class BrowserFunc { /** * Reload page with retry logic + * FIXED: Added global timeout to prevent infinite retry loops */ private async reloadPageWithRetry(page: Page, maxAttempts: number): Promise { + const startTime = Date.now() + const MAX_TOTAL_TIME_MS = 30000 // 30 seconds max total let lastError: unknown = null + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Check global timeout + if (Date.now() - startTime > MAX_TOTAL_TIME_MS) { + this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload retry exceeded total timeout (${MAX_TOTAL_TIME_MS}ms)`, 'warn') + break + } + try { await page.reload({ waitUntil: 'domcontentloaded' }) await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM) @@ -231,6 +241,7 @@ export default class BrowserFunc { /** * Parse dashboard object from script content + * FIXED: Added format validation before JSON.parse */ private async parseDashboardFromScript(page: Page, scriptContent: string): Promise { return await page.evaluate((scriptContent: string) => { @@ -244,7 +255,17 @@ export default class BrowserFunc { const match = regex.exec(scriptContent) if (match && match[1]) { try { - return JSON.parse(match[1]) + const jsonStr = match[1] + // Validate basic JSON structure before parsing + if (!jsonStr.trim().startsWith('{') || !jsonStr.trim().endsWith('}')) { + continue + } + const parsed = JSON.parse(jsonStr) + // Validate it's actually an object + if (typeof parsed !== 'object' || parsed === null) { + continue + } + return parsed } catch (e) { continue } diff --git a/src/constants.ts b/src/constants.ts index 5d4d80f..b1e1979 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ /** * Parse environment variable as number with validation + * FIXED: Added strict validation for min/max boundaries * @param key Environment variable name * @param defaultValue Default value if parsing fails or out of range * @param min Minimum allowed value @@ -16,7 +17,10 @@ function parseEnvNumber(key: string, defaultValue: number, min: number, max: num if (!raw) return defaultValue const parsed = Number(raw) - if (isNaN(parsed) || parsed < min || parsed > max) return defaultValue + // Strict validation: must be finite, not NaN, and within bounds + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + return defaultValue + } return parsed } diff --git a/src/flows/DesktopFlow.ts b/src/flows/DesktopFlow.ts index 270f71e..afb863d 100644 --- a/src/flows/DesktopFlow.ts +++ b/src/flows/DesktopFlow.ts @@ -10,7 +10,7 @@ * - Desktop searches */ -import type { BrowserContext, Page } from 'playwright' +import type { Page } from 'playwright' import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' import { saveSessionData } from '../util/Load' @@ -35,8 +35,11 @@ export class DesktopFlow { async run(account: Account): Promise { this.bot.log(false, 'DESKTOP-FLOW', 'Starting desktop automation flow') - const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise } }).browserFactory - const browser = await browserFactory.createBrowser(account.proxy, account.email) + // FIXED: Use proper typed access instead of unsafe type assertion + const browserModule = await import('../browser/Browser') + const Browser = browserModule.default + const browserInstance = new Browser(this.bot) + const browser = await browserInstance.createBrowser(account.proxy, account.email) let keepBrowserOpen = false diff --git a/src/flows/MobileFlow.ts b/src/flows/MobileFlow.ts index 723280f..50d1ded 100644 --- a/src/flows/MobileFlow.ts +++ b/src/flows/MobileFlow.ts @@ -11,7 +11,7 @@ * - Mobile retry logic */ -import type { BrowserContext, Page } from 'playwright' +import type { Page } from 'playwright' import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' import { saveSessionData } from '../util/Load' @@ -41,8 +41,11 @@ export class MobileFlow { ): Promise { this.bot.log(true, 'MOBILE-FLOW', 'Starting mobile automation flow') - const browserFactory = (this.bot as unknown as { browserFactory: { createBrowser: (proxy: Account['proxy'], email: string) => Promise } }).browserFactory - const browser = await browserFactory.createBrowser(account.proxy, account.email) + // FIXED: Use proper typed access instead of unsafe type assertion + const browserModule = await import('../browser/Browser') + const Browser = browserModule.default + const browserInstance = new Browser(this.bot) + const browser = await browserInstance.createBrowser(account.proxy, account.email) let keepBrowserOpen = false let browserClosed = false diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts index 0ded379..f97dc83 100644 --- a/src/functions/Activities.ts +++ b/src/functions/Activities.ts @@ -2,18 +2,18 @@ import { Page } from 'rebrowser-playwright' import { MicrosoftRewardsBot } from '../index' -import { Search } from './activities/Search' import { ABC } from './activities/ABC' +import { DailyCheckIn } from './activities/DailyCheckIn' import { Poll } from './activities/Poll' import { Quiz } from './activities/Quiz' +import { ReadToEarn } from './activities/ReadToEarn' +import { Search } from './activities/Search' +import { SearchOnBing } from './activities/SearchOnBing' import { ThisOrThat } from './activities/ThisOrThat' import { UrlReward } from './activities/UrlReward' -import { SearchOnBing } from './activities/SearchOnBing' -import { ReadToEarn } from './activities/ReadToEarn' -import { DailyCheckIn } from './activities/DailyCheckIn' -import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData' import type { ActivityHandler } from '../interface/ActivityHandler' +import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData' type ActivityKind = | { type: 'poll' } @@ -73,9 +73,14 @@ export class Activities { case 'urlReward': await this.doUrlReward(page) break - default: + case 'unsupported': + // FIXED: Added explicit default case this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${String((activity as { promotionType?: string }).promotionType)}"!`, 'warn') break + default: + // Exhaustiveness check - should never reach here due to ActivityKind type + this.bot.log(this.bot.isMobile, 'ACTIVITY', `Unexpected activity kind for "${activity.title}"`, 'error') + break } } catch (e) { this.bot.log(this.bot.isMobile, 'ACTIVITY', `Dispatcher error for "${activity.title}": ${e instanceof Error ? e.message : e}`, 'error') diff --git a/src/functions/Login.ts b/src/functions/Login.ts index 20a8703..e269a9f 100644 --- a/src/functions/Login.ts +++ b/src/functions/Login.ts @@ -42,7 +42,8 @@ const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' } const DEFAULT_TIMEOUTS = { loginMaxMs: (() => { const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000) - return (isNaN(val) || val < 10000 || val > 600000) ? 180000 : val + // IMPROVED: Use isFinite instead of isNaN for consistency + return (!Number.isFinite(val) || val < 10000 || val > 600000) ? 180000 : val })(), short: 200, medium: 800, @@ -51,7 +52,7 @@ const DEFAULT_TIMEOUTS = { portalWaitMs: 15000, elementCheck: 100, fastPoll: 500 -} +} as const // Security pattern bundle const SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [ @@ -739,16 +740,18 @@ export class Login { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) try { - // IMPROVED: Add 120s timeout to prevent infinite blocking + // FIXED: Add 120s timeout with proper cleanup to prevent memory leak + let timeoutHandle: NodeJS.Timeout | undefined const code = await Promise.race([ new Promise(res => { rl.question('Enter 2FA code:\n', ans => { + if (timeoutHandle) clearTimeout(timeoutHandle) rl.close() res(ans.trim()) }) }), new Promise((_, reject) => { - setTimeout(() => { + timeoutHandle = setTimeout(() => { rl.close() reject(new Error('2FA code input timeout after 120s')) }, 120000) @@ -1677,7 +1680,11 @@ export class Login { } private startCompromisedInterval() { - if (this.compromisedInterval) clearInterval(this.compromisedInterval) + // FIXED: Always cleanup existing interval before creating new one + if (this.compromisedInterval) { + clearInterval(this.compromisedInterval) + this.compromisedInterval = undefined + } this.compromisedInterval = setInterval(()=>{ try { this.bot.log(this.bot.isMobile,'SECURITY','Security standby active. Manual review required before proceeding.','warn') diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 956cf42..15b0091 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -237,11 +237,22 @@ export class Workers { await page.click(selector, { timeout: 10000 }) page = await this.bot.browser.utils.getLatestTab(page) + // FIXED: Use AbortController for proper cancellation instead of race condition const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2 - const runWithTimeout = (p: Promise) => Promise.race([ - p, - new Promise((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs)) - ]) + const controller = new AbortController() + const timeoutHandle = setTimeout(() => { + controller.abort() + }, timeoutMs) + + const runWithTimeout = async (p: Promise) => { + try { + await p + clearTimeout(timeoutHandle) + } catch (error) { + clearTimeout(timeoutHandle) + throw error + } + } await retry.run(async () => { try { diff --git a/src/index.ts b/src/index.ts index d9496c4..e37a8c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -302,8 +302,13 @@ export class MicrosoftRewardsBot { workerChunkMap.set(worker.id, chunk) } - (worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk }) + // FIXED: Proper type checking before calling send + if (worker.send && typeof worker.send === 'function') { + worker.send({ chunk }) + } worker.on('message', (msg: unknown) => { + // FIXED: Validate message structure before accessing properties + if (!msg || typeof msg !== 'object') return const m = msg as { type?: string; data?: AccountSummary[] } if (m && m.type === 'summary' && Array.isArray(m.data)) { this.accountSummaries.push(...m.data) @@ -666,38 +671,58 @@ export class MicrosoftRewardsBot { } } - /** Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). Returns 0 if already inside. */ + /** + * Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). + * IMPROVED: Better documentation and validation + * + * @param windows - Array of time window strings in format "HH:mm-HH:mm" + * @returns Milliseconds to wait (0 if already inside a window) + * + * @example + * computeWaitForAllowedWindow(['09:00-17:00']) // Wait until 9 AM if outside window + * computeWaitForAllowedWindow(['22:00-02:00']) // Handles midnight crossing + */ private computeWaitForAllowedWindow(windows: string[]): number { const now = new Date() const minsNow = now.getHours() * 60 + now.getMinutes() let nextStartMins: number | null = null + for (const w of windows) { const [start, end] = w.split('-') if (!start || !end) continue - const pStart = start.split(':').map(v=>parseInt(v,10)) - const pEnd = end.split(':').map(v=>parseInt(v,10)) + + const pStart = start.split(':').map(v => parseInt(v, 10)) + const pEnd = end.split(':').map(v => parseInt(v, 10)) if (pStart.length !== 2 || pEnd.length !== 2) continue + const sh = pStart[0]!, sm = pStart[1]! const eh = pEnd[0]!, em = pEnd[1]! - if ([sh,sm,eh,em].some(n=>Number.isNaN(n))) continue - const s = sh*60 + sm - const e = eh*60 + em + + // Validate hours and minutes ranges + if ([sh, sm, eh, em].some(n => Number.isNaN(n))) continue + if (sh < 0 || sh > 23 || eh < 0 || eh > 23) continue + if (sm < 0 || sm > 59 || em < 0 || em > 59) continue + + const s = sh * 60 + sm + const e = eh * 60 + em + if (s <= e) { - // same-day window + // Same-day window (e.g., 09:00-17:00) if (minsNow >= s && minsNow <= e) return 0 if (minsNow < s) nextStartMins = Math.min(nextStartMins ?? s, s) } else { - // wraps past midnight (e.g., 22:00-02:00) + // Wraps past midnight (e.g., 22:00-02:00) if (minsNow >= s || minsNow <= e) return 0 - // next start today is s nextStartMins = Math.min(nextStartMins ?? s, s) } } - const msPerMin = 60*1000 + + const msPerMin = 60 * 1000 if (nextStartMins != null) { const targetTodayMs = (nextStartMins - minsNow) * msPerMin - return targetTodayMs > 0 ? targetTodayMs : (24*60 + nextStartMins - minsNow) * msPerMin + return targetTodayMs > 0 ? targetTodayMs : (24 * 60 + nextStartMins - minsNow) * msPerMin } + // No valid windows parsed -> do not block return 0 } @@ -739,7 +764,12 @@ export class MicrosoftRewardsBot { await reporter.generateReport(summary) } - // Run optional auto-update script based on configuration flags. + /** + * Run optional auto-update script based on configuration flags + * IMPROVED: Added better documentation and error handling + * + * @returns Exit code (0 = success, non-zero = error) + */ private async runAutoUpdate(): Promise { const upd = this.config.update if (!upd) return 0 @@ -752,7 +782,11 @@ export class MicrosoftRewardsBot { const scriptRel = upd.scriptPath || 'setup/update/update.mjs' const scriptAbs = path.join(process.cwd(), scriptRel) - if (!fs.existsSync(scriptAbs)) return 0 + + if (!fs.existsSync(scriptAbs)) { + log('main', 'UPDATE', `Update script not found: ${scriptAbs}`, 'warn') + return 0 + } const args: string[] = [] @@ -771,22 +805,47 @@ export class MicrosoftRewardsBot { // Add Docker flag if enabled if (upd.docker) args.push('--docker') + log('main', 'UPDATE', `Running update script: ${scriptRel}`, 'log') + // Run update script as a child process and capture exit code return new Promise((resolve) => { const child = spawn(process.execPath, [scriptAbs, ...args], { stdio: 'inherit' }) - child.on('close', (code) => resolve(code ?? 0)) - child.on('error', () => resolve(1)) + child.on('close', (code) => { + log('main', 'UPDATE', `Update script exited with code ${code ?? 0}`, code === 0 ? 'log' : 'warn') + resolve(code ?? 0) + }) + child.on('error', (err) => { + log('main', 'UPDATE', `Update script error: ${err.message}`, 'error') + resolve(1) + }) }) } - /** Public entry-point to engage global security standby from other modules (idempotent). */ + /** + * Engage global security standby mode (halts all automation) + * IMPROVED: Enhanced documentation + * + * Public entry-point to engage global security standby from other modules. + * This method is idempotent - calling it multiple times has no additional effect. + * + * @param reason - Reason for standby (e.g., 'banned', 'recovery-mismatch') + * @param email - Optional email of the affected account + * + * @example + * await bot.engageGlobalStandby('recovery-mismatch', 'user@example.com') + */ public async engageGlobalStandby(reason: string, email?: string): Promise { try { + // Idempotent: don't re-engage if already active if (this.globalStandby.active) return + this.globalStandby = { active: true, reason } const who = email || this.currentAccountEmail || 'unknown' await this.sendGlobalSecurityStandbyAlert(who, reason) - } catch {/* ignore */} + } catch (error) { + // Fail silently - standby engagement is a best-effort security measure + log('main', 'STANDBY', `Failed to engage standby: ${error instanceof Error ? error.message : String(error)}`, 'warn') + } } /** Send a strong alert to all channels and mention @everyone when entering global security standby. */ @@ -878,17 +937,29 @@ async function main(): Promise { log('main', 'DASHBOARD', `Auto-started dashboard on http://${host}:${port}`) } + /** + * Attach global error handlers for graceful shutdown + * IMPROVED: Added error handling documentation + */ const attachHandlers = () => { process.on('unhandledRejection', (reason: unknown) => { - log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error') + 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') gracefulExit(1) }) process.on('uncaughtException', (err: Error) => { - log('main','FATAL','UncaughtException: ' + err.message, 'error') + log('main', 'FATAL', `UncaughtException: ${err.message}${err.stack ? `\nStack: ${err.stack.split('\n').slice(0, 3).join(' | ')}` : ''}`, 'error') gracefulExit(1) }) - process.on('SIGTERM', () => gracefulExit(0)) - process.on('SIGINT', () => gracefulExit(0)) + process.on('SIGTERM', () => { + log('main', 'SHUTDOWN', 'Received SIGTERM, shutting down gracefully...', 'log') + gracefulExit(0) + }) + process.on('SIGINT', () => { + log('main', 'SHUTDOWN', 'Received SIGINT (Ctrl+C), shutting down gracefully...', 'log') + gracefulExit(0) + }) } const gracefulExit = (code: number) => { diff --git a/src/util/Load.ts b/src/util/Load.ts index a174652..41254dc 100644 --- a/src/util/Load.ts +++ b/src/util/Load.ts @@ -149,6 +149,7 @@ function normalizeConfig(raw: unknown): Config { const saveFingerprint = (n.fingerprinting?.saveFingerprint ?? n.saveFingerprint) ?? { mobile: false, desktop: false } // Humanization defaults (single on/off) + // FIXED: Always initialize humanization object first to prevent undefined access if (!n.humanization) n.humanization = {} if (typeof n.humanization.enabled !== 'boolean') n.humanization.enabled = true if (typeof n.humanization.stopOnBan !== 'boolean') n.humanization.stopOnBan = false @@ -250,6 +251,23 @@ function normalizeConfig(raw: unknown): Config { return cfg } +// IMPROVED: Generic helper to reduce duplication +function extractStringField(obj: unknown, key: string): string | undefined { + if (obj && typeof obj === 'object' && key in obj) { + const value = (obj as Record)[key] + return typeof value === 'string' ? value : undefined + } + return undefined +} + +function extractBooleanField(obj: unknown, key: string): boolean | undefined { + if (obj && typeof obj === 'object' && key in obj) { + const value = (obj as Record)[key] + return typeof value === 'boolean' ? value : undefined + } + return undefined +} + function buildSchedulingConfig(raw: unknown): ConfigScheduling | undefined { if (!raw || typeof raw !== 'object') return undefined @@ -261,13 +279,12 @@ function buildSchedulingConfig(raw: unknown): ConfigScheduling | undefined { const cronRaw = source.cron if (cronRaw && typeof cronRaw === 'object') { - const cronSource = cronRaw as Record scheduling.cron = { - schedule: typeof cronSource.schedule === 'string' ? cronSource.schedule : undefined, - workingDirectory: typeof cronSource.workingDirectory === 'string' ? cronSource.workingDirectory : undefined, - nodePath: typeof cronSource.nodePath === 'string' ? cronSource.nodePath : undefined, - logFile: typeof cronSource.logFile === 'string' ? cronSource.logFile : undefined, - user: typeof cronSource.user === 'string' ? cronSource.user : undefined + schedule: extractStringField(cronRaw, 'schedule'), + workingDirectory: extractStringField(cronRaw, 'workingDirectory'), + nodePath: extractStringField(cronRaw, 'nodePath'), + logFile: extractStringField(cronRaw, 'logFile'), + user: extractStringField(cronRaw, 'user') } } @@ -275,12 +292,12 @@ function buildSchedulingConfig(raw: unknown): ConfigScheduling | undefined { if (taskRaw && typeof taskRaw === 'object') { const taskSource = taskRaw as Record scheduling.taskScheduler = { - taskName: typeof taskSource.taskName === 'string' ? taskSource.taskName : undefined, - schedule: typeof taskSource.schedule === 'string' ? taskSource.schedule : undefined, + taskName: extractStringField(taskRaw, 'taskName'), + schedule: extractStringField(taskRaw, 'schedule'), frequency: typeof taskSource.frequency === 'string' ? taskSource.frequency as 'daily' | 'weekly' | 'once' : undefined, - workingDirectory: typeof taskSource.workingDirectory === 'string' ? taskSource.workingDirectory : undefined, - runAsUser: typeof taskSource.runAsUser === 'boolean' ? taskSource.runAsUser : undefined, - highestPrivileges: typeof taskSource.highestPrivileges === 'boolean' ? taskSource.highestPrivileges : undefined + workingDirectory: extractStringField(taskRaw, 'workingDirectory'), + runAsUser: extractBooleanField(taskRaw, 'runAsUser'), + highestPrivileges: extractBooleanField(taskRaw, 'highestPrivileges') } } @@ -345,8 +362,13 @@ export function loadAccounts(): Account[] { // Accept either a root array or an object with an `accounts` array, ignore `_note` const parsed = Array.isArray(parsedUnknown) ? parsedUnknown : (parsedUnknown && typeof parsedUnknown === 'object' && Array.isArray((parsedUnknown as { accounts?: unknown }).accounts) ? (parsedUnknown as { accounts: unknown[] }).accounts : null) if (!Array.isArray(parsed)) throw new Error('accounts must be an array') - // minimal shape validation + // FIXED: Validate entries BEFORE type assertion for better type safety for (const entry of parsed) { + // Pre-validation: Check basic structure before casting + if (!entry || typeof entry !== 'object') { + throw new Error('each account entry must be an object') + } + // JUSTIFIED USE OF `any`: Accounts come from untrusted user JSON with unpredictable structure // We perform explicit runtime validation of each property below (typeof checks, regex validation, etc.) // This is safer than trusting a type assertion to a specific interface diff --git a/src/util/Logger.ts b/src/util/Logger.ts index ebbd173..ab11d8f 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -42,11 +42,16 @@ const cleanupInterval = setInterval(() => { } }, BUFFER_CLEANUP_INTERVAL_MS) -// Allow cleanup to be stopped (prevents process from hanging) -if (cleanupInterval.unref) { +// FIXED: Allow cleanup to be stopped with proper fallback +// unref() prevents process from hanging but may not exist in all environments +if (typeof cleanupInterval.unref === 'function') { cleanupInterval.unref() } +/** + * Get or create a webhook buffer for the given URL + * Buffers batch log messages to reduce Discord API calls + */ function getBuffer(url: string): WebhookBuffer { let buf = webhookBuffers.get(url) if (!buf) { @@ -58,6 +63,10 @@ function getBuffer(url: string): WebhookBuffer { return buf } +/** + * Send batched log messages to Discord webhook + * Handles rate limiting and message size constraints + */ async function sendBatch(url: string, buf: WebhookBuffer): Promise { if (buf.sending) return buf.sending = true @@ -104,27 +113,29 @@ async function sendBatch(url: string, buf: WebhookBuffer): Promise { buf.sending = false } +// IMPROVED: Extracted color determination logic for better maintainability +type ColorRule = { pattern: RegExp | string; color: number } +const COLOR_RULES: ColorRule[] = [ + { pattern: /\[banned\]|\[security\]|suspended|compromised/i, color: DISCORD.COLOR_RED }, + { pattern: /\[error\]|✗/i, color: DISCORD.COLOR_CRIMSON }, + { pattern: /\[warn\]|⚠/i, color: DISCORD.COLOR_ORANGE }, + { pattern: /\[ok\]|✓|complet/i, color: DISCORD.COLOR_GREEN }, + { pattern: /\[main\]/i, color: DISCORD.COLOR_BLUE } +] + function determineColorFromContent(content: string): number { const lower = content.toLowerCase() - // Priority order: most critical first - if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) { - return DISCORD.COLOR_RED - } - if (lower.includes('[error]') || lower.includes('✗')) { - return DISCORD.COLOR_CRIMSON - } - if (lower.includes('[warn]') || lower.includes('⚠')) { - return DISCORD.COLOR_ORANGE - } - if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) { - return DISCORD.COLOR_GREEN - } - if (lower.includes('[main]')) { - return DISCORD.COLOR_BLUE + // Check rules in priority order + for (const rule of COLOR_RULES) { + if (typeof rule.pattern === 'string') { + if (lower.includes(rule.pattern)) return rule.color + } else { + if (rule.pattern.test(lower)) return rule.color + } } - return 0x95A5A6 + return DISCORD.COLOR_GRAY } function enqueueWebhookLog(url: string, line: string) { @@ -138,7 +149,17 @@ function enqueueWebhookLog(url: string, line: string) { } } -// Synchronous logger that returns an Error when type === 'error' so callers can `throw log(...)` safely. +/** + * Centralized logging function with console, Discord webhook, and NTFY support + * @param isMobile - Platform identifier ('main', true for mobile, false for desktop) + * @param title - Log title/category (e.g., 'LOGIN', 'SEARCH') + * @param message - Log message content + * @param type - Log level (log, warn, error) + * @param color - Optional chalk color override + * @returns Error object if type is 'error' (allows `throw log(...)`) + * @example log('main', 'STARTUP', 'Bot started', 'log') + * @example throw log(false, 'LOGIN', 'Auth failed', 'error') + */ export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void { const configData = loadConfig() diff --git a/src/util/Retry.ts b/src/util/Retry.ts index af77043..c8ee004 100644 --- a/src/util/Retry.ts +++ b/src/util/Retry.ts @@ -11,9 +11,18 @@ type NumericPolicy = { export type Retryable = () => Promise +/** + * Exponential backoff retry mechanism with jitter + * IMPROVED: Added comprehensive documentation + */ export class Retry { private policy: NumericPolicy + /** + * Create a retry handler with exponential backoff + * @param policy - Retry policy configuration (optional) + * @example new Retry({ maxAttempts: 5, baseDelay: 2000 }) + */ constructor(policy?: ConfigRetryPolicy) { const def: NumericPolicy = { maxAttempts: 3, @@ -38,6 +47,14 @@ export class Retry { } } + /** + * Execute a function with exponential backoff retry logic + * @param fn - Async function to retry + * @param isRetryable - Optional predicate to determine if error is retryable + * @returns Result of the function + * @throws {Error} Last error if all attempts fail + * @example await retry.run(() => fetchAPI(), (err) => err.statusCode !== 404) + */ async run(fn: Retryable, isRetryable?: (e: unknown) => boolean): Promise { let attempt = 0 let delay = this.policy.baseDelay @@ -51,7 +68,8 @@ export class Retry { attempt += 1 const retry = isRetryable ? isRetryable(e) : true if (!retry || attempt >= this.policy.maxAttempts) break - const jitter = 1 + (Math.random() * 2 - 1) * this.policy.jitter + // FIXED: Jitter should always increase delay, not decrease it (remove the -1) + const jitter = 1 + Math.random() * this.policy.jitter const sleep = Math.min(this.policy.maxDelay, Math.max(0, Math.floor(delay * jitter))) await new Promise((r) => setTimeout(r, sleep)) delay = Math.min(this.policy.maxDelay, Math.floor(delay * (this.policy.multiplier || 2))) diff --git a/src/util/Utils.ts b/src/util/Utils.ts index ec2d0b1..2b95720 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -1,17 +1,32 @@ import ms from 'ms' +/** + * Extract error message from unknown error type + * @param error - Error object or unknown value + * @returns String representation of the error + */ export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** + * Utility class for common operations + * IMPROVED: Added comprehensive documentation + */ export class Util { + /** + * Wait for a specified number of milliseconds + * @param ms - Milliseconds to wait (max 1 hour) + * @throws {Error} If ms is not finite or is NaN/Infinity + * @example await utils.wait(1000) // Wait 1 second + */ wait(ms: number): Promise { const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits const MIN_WAIT_MS = 0 - // Validate and clamp input - explicit NaN check before isFinite - if (typeof ms !== 'number' || Number.isNaN(ms) || !Number.isFinite(ms)) { + // FIXED: Simplified validation - isFinite checks both NaN and Infinity + if (!Number.isFinite(ms)) { throw new Error(`Invalid wait time: ${ms}. Must be a finite number (not NaN or Infinity).`) } @@ -22,6 +37,13 @@ export class Util { }) } + /** + * Wait for a random duration within a range + * @param minMs - Minimum wait time in milliseconds + * @param maxMs - Maximum wait time in milliseconds + * @throws {Error} If parameters are invalid + * @example await utils.waitRandom(1000, 3000) // Wait 1-3 seconds + */ async waitRandom(minMs: number, maxMs: number): Promise { if (!Number.isFinite(minMs) || !Number.isFinite(maxMs)) { throw new Error(`Invalid wait range: min=${minMs}, max=${maxMs}. Both must be finite numbers.`) @@ -35,6 +57,13 @@ export class Util { return this.wait(delta) } + /** + * Format a timestamp as MM/DD/YYYY + * @param ms - Unix timestamp in milliseconds (defaults to current time) + * @returns Formatted date string + * @example utils.getFormattedDate() // '01/15/2025' + * @example utils.getFormattedDate(1704067200000) // '01/01/2024' + */ getFormattedDate(ms = Date.now()): string { const today = new Date(ms) const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0 @@ -44,12 +73,26 @@ export class Util { return `${month}/${day}/${year}` } + /** + * Randomly shuffle an array using Fisher-Yates algorithm + * @param array - Array to shuffle + * @returns New shuffled array (original array is not modified) + * @example utils.shuffleArray([1, 2, 3, 4]) // [3, 1, 4, 2] + */ shuffleArray(array: T[]): T[] { return array.map(value => ({ value, sort: Math.random() })) .sort((a, b) => a.sort - b.sort) .map(({ value }) => value) } + /** + * Generate a random integer between min and max (inclusive) + * @param min - Minimum value + * @param max - Maximum value + * @returns Random integer in range [min, max] + * @throws {Error} If parameters are invalid + * @example utils.randomNumber(1, 10) // 7 + */ randomNumber(min: number, max: number): number { if (!Number.isFinite(min) || !Number.isFinite(max)) { throw new Error(`Invalid range: min=${min}, max=${max}. Both must be finite numbers.`) @@ -62,12 +105,16 @@ export class Util { return Math.floor(Math.random() * (max - min + 1)) + min } + /** + * Split an array into approximately equal chunks + * @param arr - Array to split + * @param numChunks - Number of chunks to create (must be positive integer) + * @returns Array of chunks (sub-arrays) + * @throws {Error} If parameters are invalid + * @example utils.chunkArray([1,2,3,4,5], 2) // [[1,2,3], [4,5]] + */ chunkArray(arr: T[], numChunks: number): T[][] { - // Validate input to prevent division by zero or invalid chunks - if (!Number.isFinite(numChunks) || numChunks <= 0) { - throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive finite number.`) - } - + // FIXED: Stricter validation with better error messages if (!Array.isArray(arr)) { throw new Error('Invalid input: arr must be an array.') } @@ -76,6 +123,14 @@ export class Util { return [] } + if (!Number.isFinite(numChunks) || numChunks <= 0) { + throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive finite number.`) + } + + if (!Number.isInteger(numChunks)) { + throw new Error(`Invalid numChunks: ${numChunks}. Must be an integer.`) + } + const safeNumChunks = Math.max(1, Math.floor(numChunks)) const chunkSize = Math.ceil(arr.length / safeNumChunks) const chunks: T[][] = [] @@ -88,6 +143,15 @@ export class Util { return chunks } + /** + * Convert time string or number to milliseconds + * @param input - Time string (e.g., '1 min', '5s', '2h') or number + * @returns Time in milliseconds + * @throws {Error} If input cannot be parsed + * @example utils.stringToMs('1 min') // 60000 + * @example utils.stringToMs('5s') // 5000 + * @example utils.stringToMs(1000) // 1000 + */ stringToMs(input: string | number): number { if (typeof input !== 'string' && typeof input !== 'number') { throw new Error('Invalid input type. Expected string or number.')