Fix: Enhance error handling and timeout management across various modules; improve validation and documentation

This commit is contained in:
2025-11-08 18:52:31 +01:00
parent ca356075fa
commit 5e322af2c0
13 changed files with 341 additions and 86 deletions

View File

@@ -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')
}
}

View File

@@ -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<void> {
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<DashboardData | null> {
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
}

View File

@@ -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
}

View File

@@ -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<DesktopFlowResult> {
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<BrowserContext> } }).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

View File

@@ -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<MobileFlowResult> {
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<BrowserContext> } }).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

View File

@@ -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')

View File

@@ -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<string>(res => {
rl.question('Enter 2FA code:\n', ans => {
if (timeoutHandle) clearTimeout(timeoutHandle)
rl.close()
res(ans.trim())
})
}),
new Promise<string>((_, 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')

View File

@@ -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<void>) => Promise.race([
p,
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
])
const controller = new AbortController()
const timeoutHandle = setTimeout(() => {
controller.abort()
}, timeoutMs)
const runWithTimeout = async (p: Promise<void>) => {
try {
await p
clearTimeout(timeoutHandle)
} catch (error) {
clearTimeout(timeoutHandle)
throw error
}
}
await retry.run(async () => {
try {

View File

@@ -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<number> {
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<number>((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<void> {
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<void> {
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) => {

View File

@@ -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<string, unknown>)[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<string, unknown>)[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<string, unknown>
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<string, unknown>
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

View File

@@ -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<void> {
if (buf.sending) return
buf.sending = true
@@ -104,27 +113,29 @@ async function sendBatch(url: string, buf: WebhookBuffer): Promise<void> {
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()

View File

@@ -11,9 +11,18 @@ type NumericPolicy = {
export type Retryable<T> = () => Promise<T>
/**
* 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<T>(fn: Retryable<T>, isRetryable?: (e: unknown) => boolean): Promise<T> {
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)))

View File

@@ -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<void> {
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<void> {
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<T>(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<T>(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.')