|
@@ -0,0 +1,105 @@
|
|
|
|
|
+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 } 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<void> {
|
|
|
|
|
+ console.error(`[booking-2fa] Challenge required (${masked || 'unknown number'}) — attempting SMS automation`);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await page.getByRole('button', { name: /sms|text message/i }).first().click({ timeout: 5_000 });
|
|
|
|
|
+ console.error('[booking-2fa] Clicked SMS option');
|
|
|
|
|
+ await page.waitForTimeout(1_000);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Already on PIN entry, or SMS is the only option — continue.
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ throw new Error('[booking-2fa] No PIN input found — 2FA could not be completed automatically');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.error('[booking-2fa] Polling /sms/code for up to 2 minutes...');
|
|
|
|
|
+ const code = await fetchSmsCode(120);
|
|
|
|
|
+ if (!code) {
|
|
|
|
|
+ throw new Error('[booking-2fa] No SMS code received within timeout');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.error(`[booking-2fa] Filling code ${code}`);
|
|
|
|
|
+ await pinInput.fill(code);
|
|
|
|
|
+ await page.keyboard.press('Enter');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ let smsMasked = '';
|
|
|
|
|
+ const context = await openPersistentContext(profile, true);
|
|
|
|
|
+ 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`;
|
|
|
|
|
+
|
|
|
|
|
+ console.error('[booking-2fa] Navigating to messaging inbox (headless)...');
|
|
|
|
|
+ await page.goto(messagingUrl);
|
|
|
|
|
+ await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
+
|
|
|
|
|
+ if (!page.url().includes(ADMIN_BASE)) {
|
|
|
|
|
+ await context.close();
|
|
|
|
|
+ throw new Error(`Session invalid — redirected to ${page.url()} — run refresh-creds first`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (page.url().includes('auth-assurance')) {
|
|
|
|
|
+ await handleAuthAssurance(page, smsMasked);
|
|
|
|
|
+ await page.waitForURL(`**${ADMIN_BASE}/**`, { timeout: 120_000 });
|
|
|
|
|
+ console.error('[booking-2fa] Auth-assurance completed.');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error('[booking-2fa] No auth-assurance challenge — messaging already primed for this session.');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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); });
|
|
|
|
|
+}
|