mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
Fix: Enhance error handling and timeout management across various modules; improve validation and documentation
This commit is contained in:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
115
src/index.ts
115
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<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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user