|
|
@@ -1,143 +0,0 @@
|
|
|
-// TODO: Fold this into refresh-cookies.ts as the default headed-refresh path with a SKIP_CHAT_2FA
|
|
|
-// env flag to disable. That would make a single refresh-booking run cover both the calendar/CSRF
|
|
|
-// session and the messaging 2FA gate, letting the escalate-as-needed refresh handle everything.
|
|
|
-// trigger-2fa would then be retired or kept as a standalone re-prime entry point only.
|
|
|
-
|
|
|
-import * as fs from 'fs';
|
|
|
-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, clearSmsCode } from '../shared/api-client';
|
|
|
-import { openPersistentContext, profileDir } from '../shared/browser';
|
|
|
-import type { CredentialsPayload } from '../shared/types';
|
|
|
-
|
|
|
-const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
|
|
|
-const ENV_PATH = path.join(DATA_HOME, 'booking.env');
|
|
|
-const ADMIN_BASE = 'admin.booking.com';
|
|
|
-const STRIP = new Set(['OptanonConsent', 'pcm_consent', 'uz']);
|
|
|
-
|
|
|
-async function handleAuthAssurance(page: Page, masked: string): Promise<boolean> {
|
|
|
- // Verification options are <a> links, not buttons. Click the SMS link.
|
|
|
- try {
|
|
|
- await page.getByText(/via Text message \(SMS\)/i).click({ timeout: 10_000 });
|
|
|
- await page.waitForLoadState('domcontentloaded');
|
|
|
- } catch {
|
|
|
- // Already on PIN entry, or SMS nav already happened — continue.
|
|
|
- }
|
|
|
-
|
|
|
- // Select-phone page: click "Send verification code" to trigger the SMS.
|
|
|
- try {
|
|
|
- clearSmsCode(); // discard any stale code before triggering a new challenge
|
|
|
- await page.getByRole('button', { name: /send verification code/i }).click({ timeout: 10_000 });
|
|
|
- } 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-2fa] PIN input not found — SMS may not have been sent to ${masked || 'unknown number'}`);
|
|
|
- }
|
|
|
-
|
|
|
- console.error(`[booking-2fa] Sending SMS to ${masked || 'registered number'} — waiting for code...`);
|
|
|
- const code = await fetchSmsCode(120);
|
|
|
- if (!code) {
|
|
|
- throw new Error('[booking-2fa] No SMS code received within timeout');
|
|
|
- }
|
|
|
-
|
|
|
- await pinInput.fill(code);
|
|
|
- await page.keyboard.press('Enter');
|
|
|
- return true;
|
|
|
-}
|
|
|
-
|
|
|
-export async function triggerChatAuth(mode: string): Promise<CredentialsPayload> {
|
|
|
- const envText = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
|
|
|
- const hotelId = envText.match(/^BOOKING_HOTEL_ID=(\S+)/m)?.[1] ?? '';
|
|
|
- const ses = envText.match(/^BOOKING_SESSION=(\S+)/m)?.[1] ?? '';
|
|
|
-
|
|
|
- if (!hotelId || !ses) {
|
|
|
- throw new Error('BOOKING_HOTEL_ID and BOOKING_SESSION must be set in booking.env — run refresh-creds first');
|
|
|
- }
|
|
|
-
|
|
|
- const profile = profileDir('booking', mode);
|
|
|
- const headless = !process.env.HEADED;
|
|
|
- let smsMasked = '';
|
|
|
- const context = await openPersistentContext(profile, headless);
|
|
|
- context.on('response', async res => {
|
|
|
- if (res.url().includes('challenge-methods')) {
|
|
|
- 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) smsMasked = sms.phones[0].masked;
|
|
|
- } catch {}
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- const page = context.pages()[0] ?? await context.newPage();
|
|
|
- const messagingUrl = `https://${ADMIN_BASE}/hotel/hoteladmin/extranet_ng/manage/messaging/inbox.html?hotel_id=${hotelId}&lang=en&ses=${ses}&source=nav&tlc=1`;
|
|
|
-
|
|
|
- // Capture challenge-methods response before handleAuthAssurance needs it
|
|
|
- const challengeMethodsReady = new Promise<void>(resolve => {
|
|
|
- context.once('response', async res => {
|
|
|
- if (res.url().includes('challenge-methods')) resolve();
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- await page.goto(messagingUrl);
|
|
|
- await page.waitForLoadState('domcontentloaded');
|
|
|
-
|
|
|
- const landedUrl = page.url();
|
|
|
- const onAuthAssurance = landedUrl.includes('auth-assurance');
|
|
|
- const onAdmin = landedUrl.includes(ADMIN_BASE);
|
|
|
- if (!onAdmin && !onAuthAssurance) {
|
|
|
- await context.close();
|
|
|
- throw new Error(`Session invalid — redirected to ${landedUrl} — run refresh-creds first`);
|
|
|
- }
|
|
|
-
|
|
|
- // Wait for the MFE JS to trigger auth-assurance and challenge-methods to fire,
|
|
|
- // or bail after 10s if no challenge appears.
|
|
|
- await Promise.race([
|
|
|
- challengeMethodsReady,
|
|
|
- page.waitForURL('**auth-assurance**', { timeout: 10_000 }).catch(() => {}),
|
|
|
- new Promise<void>(r => setTimeout(r, 10_000)),
|
|
|
- ]);
|
|
|
- if (!page.url().includes('auth-assurance')) {
|
|
|
- console.error('[booking-2fa] Messaging already authorized — no challenge needed.');
|
|
|
- } else {
|
|
|
- // Wait for the auth-assurance page to render — options are links, not buttons.
|
|
|
- await page.waitForSelector('text=Text message', { timeout: 30_000 }).catch(() => {});
|
|
|
- const completed = await handleAuthAssurance(page, smsMasked);
|
|
|
- if (completed) {
|
|
|
- if (page.url().includes('auth-assurance')) {
|
|
|
- await page.waitForURL(`**${ADMIN_BASE}/**`, { timeout: 120_000 });
|
|
|
- }
|
|
|
- console.error('[booking-2fa] 2FA complete.');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!headless) {
|
|
|
- await new Promise<void>(resolve => process.stdin.once('data', () => resolve()));
|
|
|
- }
|
|
|
-
|
|
|
- const all = await context.cookies();
|
|
|
- await context.close();
|
|
|
-
|
|
|
- const cookieString = filterCookies(all, STRIP, 'booking.com');
|
|
|
- if (!cookieString) throw new Error('No cookies found after 2FA — something went wrong');
|
|
|
-
|
|
|
- console.error('[booking-2fa] Done.');
|
|
|
- return { BOOKING_COOKIES: cookieString };
|
|
|
-}
|
|
|
-
|
|
|
-async function main(): Promise<void> {
|
|
|
- const mode = parseOutputMode(process.argv);
|
|
|
- const credentials = await triggerChatAuth(mode);
|
|
|
- await handleOutput('booking', credentials, mode, ENV_PATH);
|
|
|
-}
|
|
|
-
|
|
|
-if (require.main === module) {
|
|
|
- main().catch(err => { console.error(err.message); process.exit(1); });
|
|
|
-}
|