mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import type { Page } from 'playwright'
|
|
import { MicrosoftRewardsBot } from '../../index'
|
|
import { waitForElementSmart } from '../../util/browser/SmartWait'
|
|
import { logError } from '../../util/notifications/Logger'
|
|
|
|
export class PasskeyHandler {
|
|
private bot: MicrosoftRewardsBot
|
|
private passkeyHandled = false
|
|
private noPromptIterations = 0
|
|
private lastNoPromptLog = 0
|
|
|
|
private static readonly SELECTORS = {
|
|
passkeySecondary: 'button[data-testid="secondaryButton"]',
|
|
passkeyPrimary: 'button[data-testid="primaryButton"]',
|
|
passkeyTitle: '[data-testid="title"]',
|
|
kmsiVideo: '[data-testid="kmsiVideo"]',
|
|
biometricVideo: '[data-testid="biometricVideo"]'
|
|
} as const
|
|
|
|
constructor(bot: MicrosoftRewardsBot) {
|
|
this.bot = bot
|
|
}
|
|
|
|
public async disableFido(page: Page) {
|
|
await page.route('**/GetCredentialType.srf*', route => {
|
|
try {
|
|
const body = JSON.parse(route.request().postData() || '{}')
|
|
body.isFidoSupported = false
|
|
route.continue({ postData: JSON.stringify(body) })
|
|
} catch { /* Route continue on parse failure */ route.continue() }
|
|
}).catch(logError('LOGIN-FIDO', 'Route interception setup failed', this.bot.isMobile))
|
|
}
|
|
|
|
/**
|
|
* Setup dialog handlers to automatically dismiss native browser dialogs
|
|
* CRITICAL: This handles Bluetooth/Windows Hello/Passkey prompts that appear as native browser dialogs
|
|
* These are NOT DOM elements and cannot be clicked - they must be dismissed via page.on('dialog')
|
|
*/
|
|
public setupDialogHandlers(page: Page) {
|
|
// Remove any existing listeners to prevent duplicates
|
|
page.removeAllListeners('dialog')
|
|
|
|
page.on('dialog', async (dialog) => {
|
|
const message = dialog.message()
|
|
const type = dialog.type()
|
|
|
|
this.bot.log(
|
|
this.bot.isMobile,
|
|
'LOGIN-DIALOG',
|
|
`Native browser dialog detected: [${type}] "${message.substring(0, 100)}"`,
|
|
'warn'
|
|
)
|
|
|
|
// Auto-dismiss all dialogs (alert, confirm, prompt, beforeunload)
|
|
// For passkey/Bluetooth prompts, we want to DISMISS (equivalent to Cancel)
|
|
try {
|
|
if (type === 'beforeunload') {
|
|
// Accept beforeunload to allow navigation
|
|
await dialog.accept()
|
|
this.bot.log(this.bot.isMobile, 'LOGIN-DIALOG', 'Accepted beforeunload dialog', 'log', 'green')
|
|
} else {
|
|
// Dismiss all other dialogs (passkey, Bluetooth, alerts)
|
|
await dialog.dismiss()
|
|
this.bot.log(
|
|
this.bot.isMobile,
|
|
'LOGIN-DIALOG',
|
|
`Dismissed ${type} dialog: "${message.substring(0, 50)}"`,
|
|
'log',
|
|
'green'
|
|
)
|
|
}
|
|
} catch (error) {
|
|
this.bot.log(
|
|
this.bot.isMobile,
|
|
'LOGIN-DIALOG',
|
|
`Failed to handle dialog: ${error instanceof Error ? error.message : String(error)}`,
|
|
'error'
|
|
)
|
|
}
|
|
})
|
|
|
|
this.bot.log(this.bot.isMobile, 'LOGIN-DIALOG', 'Dialog handlers installed (auto-dismiss enabled)', 'log', 'cyan')
|
|
}
|
|
|
|
public async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
|
|
let did = false
|
|
|
|
// Early exit for passkey creation flows (common on mobile): hit cancel/skip if present
|
|
const currentUrl = page.url()
|
|
if (/fido\/create|passkey/i.test(currentUrl)) {
|
|
const cancelled = await this.clickFirstVisible(page, [
|
|
'button:has-text("Cancel")',
|
|
'button:has-text("Not now")',
|
|
'button:has-text("Skip")',
|
|
'button:has-text("No thanks")',
|
|
'[data-testid="secondaryButton"]',
|
|
'button[class*="secondary"]'
|
|
], 500)
|
|
|
|
if (cancelled) {
|
|
did = true
|
|
this.logPasskeyOnce('fido/create cancel')
|
|
}
|
|
}
|
|
|
|
// Priority 1: Direct detection of "Skip for now" button by data-testid
|
|
const skipBtnResult = await waitForElementSmart(page, 'button[data-testid="secondaryButton"]', {
|
|
initialTimeoutMs: 300,
|
|
extendedTimeoutMs: 500,
|
|
state: 'visible'
|
|
})
|
|
|
|
if (skipBtnResult.found && skipBtnResult.element) {
|
|
const text = (await skipBtnResult.element.textContent() || '').trim()
|
|
// Check if it's actually a skip button (could be other secondary buttons)
|
|
if (/skip|later|not now|non merci|pas maintenant/i.test(text)) {
|
|
await skipBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Skip button click failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('data-testid secondaryButton')
|
|
}
|
|
}
|
|
|
|
// Priority 2: Video heuristic (biometric prompt)
|
|
if (!did) {
|
|
const biometricResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.biometricVideo, {
|
|
initialTimeoutMs: 300,
|
|
extendedTimeoutMs: 500,
|
|
state: 'visible'
|
|
})
|
|
|
|
if (biometricResult.found) {
|
|
const btnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
|
|
initialTimeoutMs: 200,
|
|
extendedTimeoutMs: 300,
|
|
state: 'visible'
|
|
})
|
|
if (btnResult.found && btnResult.element) {
|
|
await btnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Video heuristic click failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('video heuristic')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 3: Title + secondary button detection
|
|
if (!did) {
|
|
const titleResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeyTitle, {
|
|
initialTimeoutMs: 300,
|
|
extendedTimeoutMs: 500,
|
|
state: 'attached'
|
|
})
|
|
|
|
if (titleResult.found && titleResult.element) {
|
|
const title = (await titleResult.element.textContent() || '').trim()
|
|
const looksLike = /sign in faster|passkey|fingerprint|face|pin|empreinte|visage|windows hello|hello/i.test(title)
|
|
|
|
if (looksLike) {
|
|
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
|
|
initialTimeoutMs: 200,
|
|
extendedTimeoutMs: 300,
|
|
state: 'visible'
|
|
})
|
|
|
|
if (secBtnResult.found && secBtnResult.element) {
|
|
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Title heuristic click failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('title heuristic ' + title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check secondary button text if title heuristic didn't work
|
|
if (!did) {
|
|
const secBtnResult = await waitForElementSmart(page, PasskeyHandler.SELECTORS.passkeySecondary, {
|
|
initialTimeoutMs: 200,
|
|
extendedTimeoutMs: 300,
|
|
state: 'visible'
|
|
})
|
|
|
|
if (secBtnResult.found && secBtnResult.element) {
|
|
const text = (await secBtnResult.element.textContent() || '').trim()
|
|
if (/skip for now|not now|later|passer|plus tard/i.test(text)) {
|
|
await secBtnResult.element.click().catch(logError('LOGIN-PASSKEY', 'Secondary button text click failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('secondary button text')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 4: XPath fallback (includes Windows Hello specific patterns)
|
|
if (!did) {
|
|
const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now") or contains(normalize-space(.),"Not now") or contains(normalize-space(.),"Passer") or contains(normalize-space(.),"No thanks")]').first()
|
|
// FIXED: Add explicit timeout to isVisible
|
|
if (await textBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
await textBtn.click().catch(logError('LOGIN-PASSKEY', 'XPath fallback click failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('xpath fallback')
|
|
}
|
|
}
|
|
|
|
// Priority 4.5: Windows Hello specific detection
|
|
if (!did) {
|
|
// FIXED: Add explicit timeout
|
|
const windowsHelloTitle = await page.locator('text=/windows hello/i').first().isVisible({ timeout: 500 }).catch(() => false)
|
|
if (windowsHelloTitle) {
|
|
// Try common Windows Hello skip patterns
|
|
const skipPatterns = [
|
|
'button:has-text("Skip")',
|
|
'button:has-text("No thanks")',
|
|
'button:has-text("Maybe later")',
|
|
'button:has-text("Cancel")',
|
|
'[data-testid="secondaryButton"]',
|
|
'button[class*="secondary"]'
|
|
]
|
|
for (const pattern of skipPatterns) {
|
|
const btn = await page.locator(pattern).first()
|
|
// FIXED: Add explicit timeout
|
|
if (await btn.isVisible({ timeout: 300 }).catch(() => false)) {
|
|
await btn.click().catch(logError('LOGIN-PASSKEY', 'Windows Hello skip failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('Windows Hello skip')
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 5: Close button fallback (FIXED: Add explicit timeout instead of using page.$)
|
|
if (!did) {
|
|
const closeResult = await waitForElementSmart(page, '#close-button', {
|
|
initialTimeoutMs: 300,
|
|
extendedTimeoutMs: 500,
|
|
state: 'visible'
|
|
})
|
|
|
|
if (closeResult.found && closeResult.element) {
|
|
await closeResult.element.click().catch(logError('LOGIN-PASSKEY', 'Close button fallback failed', this.bot.isMobile))
|
|
did = true
|
|
this.logPasskeyOnce('close button')
|
|
}
|
|
}
|
|
|
|
// KMSI prompt
|
|
const kmsi = await page.waitForSelector(PasskeyHandler.SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null)
|
|
if (kmsi) {
|
|
const yes = await page.$(PasskeyHandler.SELECTORS.passkeyPrimary)
|
|
if (yes) {
|
|
await yes.click().catch(logError('LOGIN-KMSI', 'KMSI accept click failed', this.bot.isMobile))
|
|
did = true
|
|
this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt')
|
|
}
|
|
}
|
|
|
|
if (!did && context === 'main') {
|
|
this.noPromptIterations++
|
|
const now = Date.now()
|
|
if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) {
|
|
this.lastNoPromptLog = now
|
|
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
|
|
if (this.noPromptIterations > 50) this.noPromptIterations = 0
|
|
}
|
|
} else if (did) {
|
|
this.noPromptIterations = 0
|
|
}
|
|
}
|
|
|
|
private async clickFirstVisible(page: Page, selectors: string[], timeoutMs = 300): Promise<boolean> {
|
|
for (const selector of selectors) {
|
|
const el = page.locator(selector).first()
|
|
const visible = await el.isVisible({ timeout: timeoutMs }).catch(() => false)
|
|
if (!visible) continue
|
|
|
|
await el.click().catch(logError('LOGIN-PASSKEY', `Click failed for ${selector}`, this.bot.isMobile))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private logPasskeyOnce(reason: string) {
|
|
if (this.passkeyHandled) return
|
|
this.passkeyHandled = true
|
|
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
|
|
}
|
|
}
|