From 123b2f76b821f50739fbcbd57888fb597c62bcef Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Sun, 9 Nov 2025 18:45:43 +0100 Subject: [PATCH] feat: Refactor compromised mode handling and improve error logging across flows --- setup/update/update.mjs | 17 +++++++--- src/flows/DesktopFlow.ts | 29 ++--------------- src/flows/FlowUtils.ts | 63 +++++++++++++++++++++++++++++++++++++ src/flows/MobileFlow.ts | 27 ++-------------- src/functions/Activities.ts | 11 ++++--- src/functions/Workers.ts | 27 +++++++--------- src/index.ts | 55 ++++++++++++++++++-------------- src/util/Utils.ts | 32 +++++++++++++++++++ 8 files changed, 162 insertions(+), 99 deletions(-) create mode 100644 src/flows/FlowUtils.ts diff --git a/setup/update/update.mjs b/setup/update/update.mjs index 4ae36f4..8d44056 100644 --- a/setup/update/update.mjs +++ b/setup/update/update.mjs @@ -18,11 +18,10 @@ * node setup/update/update.mjs --docker # Update Docker containers */ -import { spawn, execSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { createWriteStream } from 'node:fs' +import { execSync, spawn } from 'node:child_process' +import { cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { get as httpsGet } from 'node:https' +import { dirname, join } from 'node:path' function stripJsonComments(input) { let result = "" @@ -286,11 +285,16 @@ async function updateGit() { if (!behindCount || behindCount === '0') { console.log('✓ Already up to date!') + // FIXED: Return 0 but DON'T create update marker (no restart needed) return 0 } console.log(`ℹ️ ${behindCount} commits behind remote`) + // MARK: Update is happening - create marker file for bot to detect + const updateMarkerPath = join(process.cwd(), '.update-happened') + writeFileSync(updateMarkerPath, `Updated from ${currentCommit} to latest at ${new Date().toISOString()}`) + // Use merge with strategy to accept remote changes for all files // We'll restore user files afterwards const mergeCode = await run('git', ['merge', '--strategy-option=theirs', remoteBranch]) @@ -541,6 +545,11 @@ async function updateGitFree() { rmSync(extractDir, { recursive: true, force: true }) console.log('✓ Cleanup complete') + // MARK: Update happened - create marker file for bot to detect restart + const updateMarkerPath = join(process.cwd(), '.update-happened') + writeFileSync(updateMarkerPath, `Git-free update completed at ${new Date().toISOString()}`) + console.log('✓ Created update marker for bot restart detection') + // Step 9: Install & build const hasNpm = await which('npm') if (!hasNpm) { diff --git a/src/flows/DesktopFlow.ts b/src/flows/DesktopFlow.ts index 3c74e52..858e568 100644 --- a/src/flows/DesktopFlow.ts +++ b/src/flows/DesktopFlow.ts @@ -13,7 +13,7 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' import { createBrowserInstance } from '../util/BrowserFactory' -import { saveSessionData } from '../util/Load' +import { handleCompromisedMode } from './FlowUtils' export interface DesktopFlowResult { initialPoints: number @@ -66,32 +66,9 @@ export class DesktopFlow { await this.bot.login.login(this.bot.homePage, account.email, account.password, account.totp) if (this.bot.compromisedModeActive) { - // User wants the page to remain open for manual recovery. Do not proceed to tasks. - keepBrowserOpen = true const reason = this.bot.compromisedReason || 'security-issue' - this.bot.log(false, 'DESKTOP-FLOW', `Account security check failed (${reason}). Browser kept open for manual review: ${account.email}`, 'warn', 'yellow') - - try { - const { ConclusionWebhook } = await import('../util/ConclusionWebhook') - await ConclusionWebhook( - this.bot.config, - '🔐 Security Check', - `**Account:** ${account.email}\n**Status:** ${reason}\n**Action:** Browser kept open, activities paused`, - undefined, - 0xFFAA00 - ) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.bot.log(false, 'DESKTOP-FLOW', `Failed to send security webhook: ${errorMsg}`, 'warn') - } - - // Save session for convenience, but do not close the browser - try { - await saveSessionData(this.bot.config.sessionPath, this.bot.homePage.context(), account.email, false) - } catch (e) { - this.bot.log(false, 'DESKTOP-FLOW', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn') - } - + const result = await handleCompromisedMode(this.bot, account.email, reason, false) + keepBrowserOpen = result.keepBrowserOpen return { initialPoints: 0, collectedPoints: 0 } } diff --git a/src/flows/FlowUtils.ts b/src/flows/FlowUtils.ts new file mode 100644 index 0000000..b9becf1 --- /dev/null +++ b/src/flows/FlowUtils.ts @@ -0,0 +1,63 @@ +/** + * Shared utilities for Desktop and Mobile flows + * Extracts common patterns to reduce code duplication + */ + +import type { MicrosoftRewardsBot } from '../index' +import { saveSessionData } from '../util/Load' + +/** + * Handle compromised/security check mode for an account + * Sends security alert webhook, saves session, and keeps browser open for manual review + * + * @param bot Bot instance + * @param account Email of affected account + * @param reason Reason for security check (e.g., 'recovery-email-mismatch', '2fa-required') + * @param isMobile Whether this is mobile flow (affects logging context) + * @returns Object with keepBrowserOpen flag (always true for compromised mode) + * + * @example + * const result = await handleCompromisedMode(bot, 'user@example.com', 'recovery-mismatch', false) + * if (result.keepBrowserOpen) return { initialPoints: 0, collectedPoints: 0 } + */ +export async function handleCompromisedMode( + bot: MicrosoftRewardsBot, + account: string, + reason: string, + isMobile: boolean +): Promise<{ keepBrowserOpen: boolean }> { + const flowContext = isMobile ? 'MOBILE-FLOW' : 'DESKTOP-FLOW' + + bot.log( + isMobile, + flowContext, + `Account security check failed (${reason}). Browser kept open for manual review: ${account}`, + 'warn', + 'yellow' + ) + + // Send security alert webhook + try { + const { ConclusionWebhook } = await import('../util/ConclusionWebhook') + await ConclusionWebhook( + bot.config, + isMobile ? '🔐 Security Check (Mobile)' : '🔐 Security Check', + `**Account:** ${account}\n**Status:** ${reason}\n**Action:** Browser kept open, ${isMobile ? 'mobile ' : ''}activities paused`, + undefined, + 0xFFAA00 + ) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + bot.log(isMobile, flowContext, `Failed to send security webhook: ${errorMsg}`, 'warn') + } + + // Save session for convenience (non-critical) + try { + await saveSessionData(bot.config.sessionPath, bot.homePage.context(), account, isMobile) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + bot.log(isMobile, flowContext, `Failed to save session: ${errorMsg}`, 'warn') + } + + return { keepBrowserOpen: true } +} diff --git a/src/flows/MobileFlow.ts b/src/flows/MobileFlow.ts index 477dc5c..69b2d18 100644 --- a/src/flows/MobileFlow.ts +++ b/src/flows/MobileFlow.ts @@ -14,8 +14,8 @@ import type { MicrosoftRewardsBot } from '../index' import type { Account } from '../interface/Account' import { createBrowserInstance } from '../util/BrowserFactory' -import { saveSessionData } from '../util/Load' import { MobileRetryTracker } from '../util/MobileRetryTracker' +import { handleCompromisedMode } from './FlowUtils' export interface MobileFlowResult { initialPoints: number @@ -73,30 +73,9 @@ export class MobileFlow { await this.bot.login.login(this.bot.homePage, account.email, account.password, account.totp) if (this.bot.compromisedModeActive) { - keepBrowserOpen = true const reason = this.bot.compromisedReason || 'security-issue' - this.bot.log(true, 'MOBILE-FLOW', `Mobile security check failed (${reason}). Browser kept open for manual review: ${account.email}`, 'warn', 'yellow') - - try { - const { ConclusionWebhook } = await import('../util/ConclusionWebhook') - await ConclusionWebhook( - this.bot.config, - '🔐 Security Check (Mobile)', - `**Account:** ${account.email}\n**Status:** ${reason}\n**Action:** Browser kept open, mobile activities paused`, - undefined, - 0xFFAA00 - ) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.bot.log(true, 'MOBILE-FLOW', `Failed to send security webhook: ${errorMsg}`, 'warn') - } - - try { - await saveSessionData(this.bot.config.sessionPath, this.bot.homePage.context(), account.email, true) - } catch (e) { - this.bot.log(true, 'MOBILE-FLOW', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn') - } - + const result = await handleCompromisedMode(this.bot, account.email, reason, true) + keepBrowserOpen = result.keepBrowserOpen return { initialPoints: 0, collectedPoints: 0 } } diff --git a/src/functions/Activities.ts b/src/functions/Activities.ts index eb0d03c..f5e273f 100644 --- a/src/functions/Activities.ts +++ b/src/functions/Activities.ts @@ -92,13 +92,14 @@ export class Activities { await this.doUrlReward(page) break 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') + this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${(activity as { promotionType?: string }).promotionType || 'unknown'}"`, 'warn') break - default: - // Exhaustiveness check - should never reach here due to ActivityKind type + default: { + // Exhaustiveness check - TypeScript ensures all ActivityKind types are handled + const _exhaustive: never = kind this.bot.log(this.bot.isMobile, 'ACTIVITY', `Unexpected activity kind for "${activity.title}"`, 'error') - break + return _exhaustive + } } } 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/Workers.ts b/src/functions/Workers.ts index 2777825..1fa419c 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -237,26 +237,21 @@ 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 + // Execute activity with timeout protection using Promise.race const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2 - 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 () => { + const activityPromise = this.bot.activities.run(page, activity) + const timeoutPromise = new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Activity execution timeout after ${timeoutMs}ms`)) + }, timeoutMs) + // Clean up timer if activity completes first + activityPromise.finally(() => clearTimeout(timer)) + }) + try { - await runWithTimeout(this.bot.activities.run(page, activity)) + await Promise.race([activityPromise, timeoutPromise]) throttle.record(true) } catch (e) { throttle.record(false) diff --git a/src/index.ts b/src/index.ts index 522f1c6..4ae295b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import { log } from './util/Logger' import { MobileRetryTracker } from './util/MobileRetryTracker' import { QueryDiversityEngine } from './util/QueryDiversityEngine' import { StartupValidator } from './util/StartupValidator' -import { Util } from './util/Utils' +import { formatDetailedError, shortErrorMessage, Util } from './util/Utils' import { Activities } from './functions/Activities' import { Login } from './functions/Login' @@ -877,20 +877,9 @@ function isWorkerMessage(msg: unknown): msg is WorkerMessage { return m.type === 'summary' && Array.isArray(m.data) } -function shortErr(e: unknown): string { - if (e == null) return 'unknown' - if (e instanceof Error) return e.message.substring(0, 120) - const s = String(e) - return s.substring(0, 120) -} - -function formatFullError(label: string, e: unknown, verbose: boolean): string { - const base = shortErr(e) - if (verbose && e instanceof Error && e.stack) { - return `${label}:${base} :: ${e.stack.split('\n').slice(0, 4).join(' | ')}` - } - return `${label}:${base}` -} +// Use utility functions from Utils.ts +const shortErr = shortErrorMessage +const formatFullError = formatDetailedError async function main(): Promise { // Check for dashboard mode flag (standalone dashboard) @@ -981,23 +970,41 @@ async function main(): Promise { const bootstrap = async () => { try { // Check for updates BEFORE initializing and running tasks + // CRITICAL: Only restart if update script explicitly indicates new version was installed try { const updateResult = await rewardsBot.runAutoUpdate().catch((e) => { log('main', 'UPDATE', `Auto-update check failed: ${e instanceof Error ? e.message : String(e)}`, 'warn') return -1 }) + // FIXED: Only restart on exit code 0 AND if update actually happened + // The update script returns 0 even when no update is needed, which causes infinite loop + // Solution: Check for marker file that update script creates when actual update happens if (updateResult === 0) { - log('main', 'UPDATE', '✅ Update successful - restarting with new version...', 'log', 'green') + const updateMarkerPath = path.join(process.cwd(), '.update-happened') + const updateHappened = fs.existsSync(updateMarkerPath) - // Restart the process with the same arguments - const { spawn } = await import('child_process') - const child = spawn(process.execPath, process.argv.slice(1), { - detached: true, - stdio: 'inherit' - }) - child.unref() - process.exit(0) + if (updateHappened) { + // Remove marker file + try { + fs.unlinkSync(updateMarkerPath) + } catch { + // Ignore cleanup errors + } + + log('main', 'UPDATE', '✅ Update successful - restarting with new version...', 'log', 'green') + + // Restart the process with the same arguments + const { spawn } = await import('child_process') + const child = spawn(process.execPath, process.argv.slice(1), { + detached: true, + stdio: 'inherit' + }) + child.unref() + process.exit(0) + } else { + log('main', 'UPDATE', 'Already up to date, continuing with bot execution') + } } } catch (updateError) { log('main', 'UPDATE', `Update check failed (continuing): ${updateError instanceof Error ? updateError.message : String(updateError)}`, 'warn') diff --git a/src/util/Utils.ts b/src/util/Utils.ts index 6245c10..3e7c6e1 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -169,4 +169,36 @@ export class Util { return milisec } +} + +/** + * Extract short error message from unknown error type (max 120 chars) + * @param error - Error object or unknown value + * @returns Truncated string representation of the error + * @example shortErrorMessage(new Error('Something went wrong')) // 'Something went wrong' + * @example shortErrorMessage(null) // 'unknown' + */ +export function shortErrorMessage(error: unknown): string { + if (error == null) return 'unknown' + if (error instanceof Error) return error.message.substring(0, 120) + const str = String(error) + return str.substring(0, 120) +} + +/** + * Format detailed error message with optional stack trace + * @param label - Error context label (e.g., 'desktop', 'mobile', 'login') + * @param error - Error object or unknown value + * @param includeStack - Whether to include stack trace (default: false) + * @returns Formatted error string with label and optionally stack trace + * @example formatDetailedError('desktop', new Error('Failed'), true) // 'desktop:Failed :: at line1 | at line2...' + * @example formatDetailedError('mobile', 'timeout') // 'mobile:timeout' + */ +export function formatDetailedError(label: string, error: unknown, includeStack: boolean = false): string { + const baseMessage = shortErrorMessage(error) + if (includeStack && error instanceof Error && error.stack) { + const stackLines = error.stack.split('\n').slice(0, 4).join(' | ') + return `${label}:${baseMessage} :: ${stackLines}` + } + return `${label}:${baseMessage}` } \ No newline at end of file