|
|
@@ -3,7 +3,7 @@ import * as path from 'path';
|
|
|
import * as os from 'os';
|
|
|
import type { BrowserContext, Page } from 'playwright';
|
|
|
import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
|
|
|
-import { handleOutput, fetchSmsCode } from '../shared/api-client';
|
|
|
+import { handleOutput, fetchSmsCode, clearSmsCode } from '../shared/api-client';
|
|
|
import { openPersistentContext, profileDir } from '../shared/browser';
|
|
|
import type { CredentialsPayload } from '../shared/types';
|
|
|
|
|
|
@@ -110,19 +110,33 @@ async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
|
|
|
console.error(`[booking] 2FA required (${masked || 'unknown number'}) — attempting SMS automation`);
|
|
|
|
|
|
// The challenge picker may show multiple options; try to click SMS.
|
|
|
+ // On account.booking.com/auth-assurance the options may be buttons or links depending on flow.
|
|
|
try {
|
|
|
await page.getByRole('button', { name: /sms|text message/i }).first().click({ timeout: 5_000 });
|
|
|
console.error('[booking] Clicked SMS option');
|
|
|
- await page.waitForTimeout(1_000);
|
|
|
} catch {
|
|
|
- // Already on PIN entry, or SMS is the only option shown automatically — continue.
|
|
|
+ try {
|
|
|
+ await page.getByText(/via Text message \(SMS\)/i).first().click({ timeout: 3_000 });
|
|
|
+ console.error('[booking] Clicked SMS link');
|
|
|
+ } catch {
|
|
|
+ // Already on PIN entry, or SMS is the only option shown automatically — continue.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Click "Send verification code" to trigger the SMS if on the phone-select page.
|
|
|
+ try {
|
|
|
+ clearSmsCode(); // discard any stale code before triggering a new challenge
|
|
|
+ await page.getByRole('button', { name: /send verification code/i }).click({ timeout: 5_000 });
|
|
|
+ console.error('[booking] Triggered SMS send');
|
|
|
+ } catch {
|
|
|
+ // Already past this step, or button not present.
|
|
|
}
|
|
|
|
|
|
// Wait for the one-time-code input to appear.
|
|
|
const PIN_SEL = 'input[autocomplete="one-time-code"], input[inputmode="numeric"][maxlength], input[name*="pin"], input[name*="code"]';
|
|
|
let pinInput;
|
|
|
try {
|
|
|
- pinInput = await page.waitForSelector(PIN_SEL, { timeout: 15_000 });
|
|
|
+ pinInput = await page.waitForSelector(PIN_SEL, { timeout: 30_000 });
|
|
|
} catch {
|
|
|
console.error('[booking] No PIN input found — complete 2FA manually in the browser');
|
|
|
return;
|
|
|
@@ -140,10 +154,91 @@ async function handleAuthAssurance(page: Page, masked: string): Promise<void> {
|
|
|
await page.keyboard.press('Enter');
|
|
|
}
|
|
|
|
|
|
+// Prime the messaging 2FA session by navigating to the inbox and completing any auth-assurance
|
|
|
+// challenge. This uses the same SMS automation path as handleAuthAssurance above but targets the
|
|
|
+// messaging-specific auth-assurance page, which uses <a> links instead of buttons for options.
|
|
|
+async function handleMessaging2FA(page: Page, masked: string): Promise<void> {
|
|
|
+ try {
|
|
|
+ await page.getByText(/via Text message \(SMS\)/i).click({ timeout: 10_000 });
|
|
|
+ await page.waitForLoadState('domcontentloaded');
|
|
|
+ } catch {
|
|
|
+ // Already on PIN entry, or SMS is the only option shown automatically.
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ clearSmsCode();
|
|
|
+ await page.getByRole('button', { name: /send verification code/i }).click({ timeout: 10_000 });
|
|
|
+ console.error('[booking] Triggered messaging SMS send');
|
|
|
+ } catch {
|
|
|
+ // Already past this step.
|
|
|
+ }
|
|
|
+
|
|
|
+ const PIN_SEL = 'input[autocomplete="one-time-code"], input[inputmode="numeric"][maxlength], input[name*="pin"], input[name*="code"]';
|
|
|
+ let pinInput;
|
|
|
+ try {
|
|
|
+ pinInput = await page.waitForSelector(PIN_SEL, { timeout: 30_000 });
|
|
|
+ } catch {
|
|
|
+ throw new Error(`[booking] PIN input not found for messaging 2FA — SMS may not have been sent to ${masked || 'unknown number'}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ console.error('[booking] Messaging 2FA: polling /sms/code for up to 2 minutes...');
|
|
|
+ const code = await fetchSmsCode(120);
|
|
|
+ if (!code) throw new Error('[booking] No SMS code received for messaging 2FA within timeout');
|
|
|
+
|
|
|
+ console.error(`[booking] Filling messaging code ${code}`);
|
|
|
+ await pinInput.fill(code);
|
|
|
+ await page.keyboard.press('Enter');
|
|
|
+}
|
|
|
+
|
|
|
+async function primeMessagingSession(
|
|
|
+ context: BrowserContext,
|
|
|
+ page: Page,
|
|
|
+ hotelId: string,
|
|
|
+ ses: string,
|
|
|
+ smsMasked: string,
|
|
|
+): Promise<void> {
|
|
|
+ const messagingUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/messaging/inbox.html?hotel_id=${hotelId}&lang=en&ses=${ses}&source=nav&tlc=1`;
|
|
|
+ let masked = smsMasked;
|
|
|
+
|
|
|
+ // Keep smsMasked current in case a new challenge-methods response fires during navigation.
|
|
|
+ const challengeListener = async (res: import('playwright').Response) => {
|
|
|
+ if (!res.url().includes('challenge-methods')) return;
|
|
|
+ try {
|
|
|
+ const body = await res.json() as { challengeMethods?: Array<{ type: string; phones?: Array<{ masked: string }> }> };
|
|
|
+ const sms = body.challengeMethods?.find(m => m.type === 'CHALLENGE_METHOD_SMS');
|
|
|
+ if (sms?.phones?.[0]?.masked) masked = sms.phones[0].masked;
|
|
|
+ } catch {}
|
|
|
+ };
|
|
|
+ context.on('response', challengeListener);
|
|
|
+
|
|
|
+ try {
|
|
|
+ console.error('[booking] Navigating to messaging inbox to prime chat session...');
|
|
|
+ await page.goto(messagingUrl);
|
|
|
+ await page.waitForLoadState('domcontentloaded');
|
|
|
+
|
|
|
+ await Promise.race([
|
|
|
+ page.waitForURL('**auth-assurance**', { timeout: 10_000 }).catch(() => {}),
|
|
|
+ new Promise<void>(r => setTimeout(r, 5_000)),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (page.url().includes('auth-assurance')) {
|
|
|
+ await page.waitForSelector('text=Text message', { timeout: 30_000 }).catch(() => {});
|
|
|
+ await handleMessaging2FA(page, masked);
|
|
|
+ await page.waitForURL(`**${ADMIN_BASE}/**`, { timeout: 120_000 });
|
|
|
+ console.error('[booking] Messaging 2FA complete.');
|
|
|
+ } else {
|
|
|
+ console.error('[booking] Messaging already authorized — no 2FA needed.');
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ context.off('response', challengeListener);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
const ENV_PATH = path.join(DATA_HOME, 'booking.env');
|
|
|
const ADMIN_BASE = 'admin.booking.com';
|
|
|
const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
|
|
|
const FORCE_HEADLESS = !!process.env.HEADLESS;
|
|
|
+const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
|
|
|
|
|
|
export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
const profile = profileDir('booking', mode);
|
|
|
@@ -280,10 +375,9 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
|
|
|
|
|
|
const session = new URL(page.url()).searchParams.get('ses') ?? ses;
|
|
|
|
|
|
- // TODO: Navigate to messaging inbox to prime auth-assurance for chat access.
|
|
|
- // This is a separate 2FA gate; without it, messaging GraphQL calls may fail on a cold session.
|
|
|
- // Skipped for now because the SMS 2FA prompt slows down unattended credential refreshes.
|
|
|
- // Re-enable once we have a reliable way to handle or suppress the auth-assurance challenge.
|
|
|
+ if (!SKIP_CHAT_2FA) {
|
|
|
+ await primeMessagingSession(context, page, hotelId, session, smsMasked);
|
|
|
+ }
|
|
|
|
|
|
console.error('Capturing cookies...');
|
|
|
const all = await context.cookies();
|