feat: Refactor compromised mode handling and improve error logging across flows

This commit is contained in:
2025-11-09 18:45:43 +01:00
parent 3df985c7d9
commit 123b2f76b8
8 changed files with 162 additions and 99 deletions

View File

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

View File

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

63
src/flows/FlowUtils.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -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<void>) => {
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<never>((_, 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)

View File

@@ -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<void> {
// Check for dashboard mode flag (standalone dashboard)
@@ -981,23 +970,41 @@ async function main(): Promise<void> {
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')

View File

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