v3.1.0 initial

This commit is contained in:
TheNetsky
2026-01-05 16:26:47 +01:00
parent a8ddb65b21
commit 576899f39d
37 changed files with 3391 additions and 865 deletions

View File

@@ -0,0 +1,129 @@
import type { Page } from 'patchright'
import type { MicrosoftRewardsBot } from '../../../index'
import { getErrorMessage, getSubtitleMessage, promptInput } from './LoginUtils'
export class CodeLogin {
private readonly textInputSelector = '[data-testid="codeInputWrapper"]'
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
private readonly maxManualSeconds = 60
private readonly maxManualAttempts = 5
constructor(private bot: MicrosoftRewardsBot) {}
private async fillCode(page: Page, code: string): Promise<boolean> {
try {
const visibleInput = await page
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
.catch(() => null)
if (visibleInput) {
await page.keyboard.type(code, { delay: 50 })
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
return true
}
const secondairyInput = await page.$(this.secondairyInputSelector)
if (secondairyInput) {
await page.keyboard.type(code, { delay: 50 })
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
return true
}
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'No code input field found')
return false
} catch (error) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-CODE',
`Failed to fill code input: ${error instanceof Error ? error.message : String(error)}`
)
return false
}
}
async handle(page: Page): Promise<void> {
try {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code login authentication requested')
const emailMessage = await getSubtitleMessage(page)
if (emailMessage) {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `Page message: "${emailMessage}"`)
} else {
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination')
}
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
const code = await promptInput({
question: `Enter the 6-digit code (waiting ${this.maxManualSeconds}s): `,
timeoutSeconds: this.maxManualSeconds,
validate: code => /^\d{6}$/.test(code)
})
if (!code || !/^\d{6}$/.test(code)) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-CODE',
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Manual code input failed or timed out')
}
continue
}
const filled = await this.fillCode(page, code)
if (!filled) {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-CODE',
`Unable to fill code input (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Code input field not found')
}
continue
}
await this.bot.utils.wait(500)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
// Check if wrong code was entered
const errorMessage = await getErrorMessage(page)
if (errorMessage) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-CODE',
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error(`Maximum attempts reached: ${errorMessage}`)
}
// Clear the input field before retrying
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
if (inputToClear) {
await inputToClear.click()
await page.keyboard.press('Control+A')
await page.keyboard.press('Backspace')
}
continue
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code authentication completed successfully')
return
}
throw new Error(`Code input failed after ${this.maxManualAttempts} attempts`)
} catch (error) {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-CODE',
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}
}
}

View File

@@ -0,0 +1,66 @@
import type { Page } from 'patchright'
import readline from 'readline'
export interface PromptOptions {
question: string
timeoutSeconds?: number
validate?: (input: string) => boolean
transform?: (input: string) => string
}
export function promptInput(options: PromptOptions): Promise<string | null> {
const { question, timeoutSeconds = 60, validate, transform } = options
return new Promise(resolve => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
let resolved = false
const cleanup = (result: string | null) => {
if (resolved) return
resolved = true
clearTimeout(timer)
rl.close()
resolve(result)
}
const timer = setTimeout(() => cleanup(null), timeoutSeconds * 1000)
rl.question(question, answer => {
let value = answer.trim()
if (transform) value = transform(value)
if (validate && !validate(value)) {
cleanup(null)
return
}
cleanup(value)
})
})
}
export async function getSubtitleMessage(page: Page): Promise<string | null> {
const message = await page
.waitForSelector('[data-testid="subtitle"]', { state: 'visible', timeout: 1000 })
.catch(() => null)
if (!message) return null
const text = await message.innerText()
return text.trim()
}
export async function getErrorMessage(page: Page): Promise<string | null> {
const errorAlert = await page
.waitForSelector('div[role="alert"]', { state: 'visible', timeout: 1000 })
.catch(() => null)
if (!errorAlert) return null
const text = await errorAlert.innerText()
return text.trim()
}

View File

@@ -1,7 +1,6 @@
import type { Page } from 'patchright'
import { randomBytes } from 'crypto'
import { URLSearchParams } from 'url'
import type { AxiosRequestConfig } from 'axios'
import type { MicrosoftRewardsBot } from '../../../index'
@@ -29,25 +28,70 @@ export class MobileAccessLogin {
authorizeUrl.searchParams.append('access_type', 'offline_access')
authorizeUrl.searchParams.append('login_hint', email)
this.bot.logger.debug(
this.bot.isMobile,
'LOGIN-APP',
`Auth URL constructed: ${authorizeUrl.origin}${authorizeUrl.pathname}`
)
await this.bot.browser.utils.disableFido(this.page)
await this.page.goto(authorizeUrl.href).catch(() => {})
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Navigating to OAuth authorize URL')
await this.page.goto(authorizeUrl.href).catch(err => {
this.bot.logger.debug(
this.bot.isMobile,
'LOGIN-APP',
`page.goto() failed: ${err instanceof Error ? err.message : String(err)}`
)
})
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...')
const start = Date.now()
let code = ''
let lastUrl = ''
while (Date.now() - start < this.maxTimeout) {
const url = new URL(this.page.url())
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
code = url.searchParams.get('code') || ''
if (code) break
const currentUrl = this.page.url()
// Log only when URL changes (high signal, no spam)
if (currentUrl !== lastUrl) {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `OAuth poll URL changed → ${currentUrl}`)
lastUrl = currentUrl
}
try {
const url = new URL(currentUrl)
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
code = url.searchParams.get('code') || ''
if (code) {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'OAuth code detected in redirect URL')
break
}
}
} catch (err) {
this.bot.logger.debug(
this.bot.isMobile,
'LOGIN-APP',
`Invalid URL while polling: ${String(currentUrl)}`
)
}
await this.bot.utils.wait(1000)
}
if (!code) {
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'Timed out waiting for OAuth code')
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-APP',
`Timed out waiting for OAuth code after ${Math.round((Date.now() - start) / 1000)}s`
)
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `Final page URL: ${this.page.url()}`)
return ''
}
@@ -57,18 +101,24 @@ export class MobileAccessLogin {
data.append('code', code)
data.append('redirect_uri', this.redirectUrl)
const request: AxiosRequestConfig = {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Exchanging OAuth code for access token')
const response = await this.bot.axios.request({
url: this.tokenUrl,
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: data.toString()
}
})
const response = await this.bot.axios.request(request)
const token = (response?.data?.access_token as string) ?? ''
if (!token) {
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response')
this.bot.logger.debug(
this.bot.isMobile,
'LOGIN-APP',
`Token response payload: ${JSON.stringify(response?.data)}`
)
return ''
}
@@ -78,10 +128,11 @@ export class MobileAccessLogin {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-APP',
`MobileAccess error: ${error instanceof Error ? error.message : String(error)}`
`MobileAccess error: ${error instanceof Error ? error.stack || error.message : String(error)}`
)
return ''
} finally {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Returning to base URL')
await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {})
}
}

View File

@@ -0,0 +1,187 @@
import type { Page } from 'patchright'
import type { MicrosoftRewardsBot } from '../../../index'
import { getErrorMessage, promptInput } from './LoginUtils'
export class RecoveryLogin {
private readonly textInputSelector = '[data-testid="proof-confirmation"]'
private readonly maxManualSeconds = 60
private readonly maxManualAttempts = 5
constructor(private bot: MicrosoftRewardsBot) {}
private async fillEmail(page: Page, email: string): Promise<boolean> {
try {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Attempting to fill email: ${email}`)
const visibleInput = await page
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
.catch(() => null)
if (visibleInput) {
await page.keyboard.type(email, { delay: 50 })
await page.keyboard.press('Enter')
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Successfully filled email input field')
return true
}
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Email input field not found with selector: ${this.textInputSelector}`
)
return false
} catch (error) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Failed to fill email input: ${error instanceof Error ? error.message : String(error)}`
)
return false
}
}
async handle(page: Page, recoveryEmail: string): Promise<void> {
try {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email recovery authentication flow initiated')
if (recoveryEmail) {
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Using provided recovery email: ${recoveryEmail}`
)
const filled = await this.fillEmail(page, recoveryEmail)
if (!filled) {
throw new Error('Email input field not found')
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
await this.bot.utils.wait(500)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
})
const errorMessage = await getErrorMessage(page)
if (errorMessage) {
throw new Error(`Email verification failed: ${errorMessage}`)
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
return
}
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-RECOVERY',
'No recovery email provided, will prompt user for input'
)
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Starting attempt ${attempt}/${this.maxManualAttempts}`
)
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Prompting user for email input (timeout: ${this.maxManualSeconds}s)`
)
const email = await promptInput({
question: `Recovery email (waiting ${this.maxManualSeconds}s): `,
timeoutSeconds: this.maxManualSeconds,
validate: email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
})
if (!email) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-RECOVERY',
`No or invalid email input received (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Manual email input failed: no input received')
}
continue
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Invalid email format received (attempt ${attempt}/${this.maxManualAttempts}) | length=${email.length}`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Manual email input failed: invalid format')
}
continue
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Valid email received from user: ${email}`)
const filled = await this.fillEmail(page, email)
if (!filled) {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Failed to fill email input field (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Email input field not found after maximum attempts')
}
await this.bot.utils.wait(1000)
continue
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
await this.bot.utils.wait(500)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
})
const errorMessage = await getErrorMessage(page)
if (errorMessage) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-RECOVERY',
`Error from page: "${errorMessage}" (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error(`Maximum attempts reached. Last error: ${errorMessage}`)
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Clearing input field for retry')
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
if (inputToClear) {
await inputToClear.click()
await page.keyboard.press('Control+A')
await page.keyboard.press('Backspace')
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Input field cleared')
} else {
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', 'Could not find input field to clear')
}
await this.bot.utils.wait(1000)
continue
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
return
}
throw new Error(`Email input failed after ${this.maxManualAttempts} attempts`)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `Fatal error: ${errorMsg}`)
throw error
}
}
}

View File

@@ -1,12 +1,12 @@
import type { Page } from 'patchright'
import * as OTPAuth from 'otpauth'
import readline from 'readline'
import type { MicrosoftRewardsBot } from '../../../index'
import { getErrorMessage, promptInput } from './LoginUtils'
export class TotpLogin {
private readonly textInputSelector =
'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5'
private readonly hiddenInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
private readonly submitButtonSelector = 'button[type="submit"]'
private readonly maxManualSeconds = 60
private readonly maxManualAttempts = 5
@@ -17,31 +17,6 @@ export class TotpLogin {
return new OTPAuth.TOTP({ secret, digits: 6 }).generate()
}
private async promptManualCode(): Promise<string | null> {
return await new Promise(resolve => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
let resolved = false
const cleanup = (result: string | null) => {
if (resolved) return
resolved = true
clearTimeout(timer)
rl.close()
resolve(result)
}
const timer = setTimeout(() => cleanup(null), this.maxManualSeconds * 1000)
rl.question(`Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `, answer => {
cleanup(answer.trim())
})
})
}
private async fillCode(page: Page, code: string): Promise<boolean> {
try {
const visibleInput = await page
@@ -50,19 +25,18 @@ export class TotpLogin {
if (visibleInput) {
await visibleInput.fill(code)
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled visible TOTP text input')
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
return true
}
const hiddenInput = await page.$(this.hiddenInputSelector)
if (hiddenInput) {
await hiddenInput.fill(code)
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled hidden TOTP input')
const secondairyInput = await page.$(this.secondairyInputSelector)
if (secondairyInput) {
await secondairyInput.fill(code)
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
return true
}
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found (visible or hidden)')
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found')
return false
} catch (error) {
this.bot.logger.warn(
@@ -83,9 +57,8 @@ export class TotpLogin {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret')
const filled = await this.fillCode(page, code)
if (!filled) {
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to locate or fill TOTP input field')
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to fill TOTP input field')
throw new Error('TOTP input field not found')
}
@@ -93,6 +66,12 @@ export class TotpLogin {
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
const errorMessage = await getErrorMessage(page)
if (errorMessage) {
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP failed: ${errorMessage}`)
throw new Error(`TOTP authentication failed: ${errorMessage}`)
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
return
}
@@ -100,45 +79,36 @@ export class TotpLogin {
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input')
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
const code = await this.promptManualCode()
const code = await promptInput({
question: `Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `,
timeoutSeconds: this.maxManualSeconds,
validate: code => /^\d{6}$/.test(code)
})
if (!code || !/^\d{6}$/.test(code)) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-TOTP',
`Invalid or missing TOTP code (attempt ${attempt}/${this.maxManualAttempts})`
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
)
if (attempt === this.maxManualAttempts) {
throw new Error('Manual TOTP input failed or timed out')
}
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-TOTP',
'Retrying manual TOTP input due to invalid code'
)
continue
}
const filled = await this.fillCode(page, code)
if (!filled) {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-TOTP',
`Unable to locate or fill TOTP input field (attempt ${attempt}/${this.maxManualAttempts})`
`Unable to fill TOTP input (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error('TOTP input field not found')
}
this.bot.logger.info(
this.bot.isMobile,
'LOGIN-TOTP',
'Retrying manual TOTP input due to fill failure'
)
continue
}
@@ -146,16 +116,31 @@ export class TotpLogin {
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
// Check if wrong code was entered
const errorMessage = await getErrorMessage(page)
if (errorMessage) {
this.bot.logger.warn(
this.bot.isMobile,
'LOGIN-TOTP',
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
)
if (attempt === this.maxManualAttempts) {
throw new Error(`Maximum attempts reached: ${errorMessage}`)
}
continue
}
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
return
}
throw new Error(`Manual TOTP input failed after ${this.maxManualAttempts} attempts`)
throw new Error(`TOTP input failed after ${this.maxManualAttempts} attempts`)
} catch (error) {
this.bot.logger.error(
this.bot.isMobile,
'LOGIN-TOTP',
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
)
throw error
}