Procházet zdrojové kódy

Unify Booking.com 2FA into standard credential refresh

Remove standalone trigger-2fa.ts and the booking-2fa entry point.
primeMessagingSession now runs by default at the end of every
refresh-booking run; set SKIP_CHAT_2FA=1 to bypass. Messaging 2FA
failures are caught so valid calendar credentials are always written.

send_message ISE retry now calls refresh_booking_cookies() (full
refresh including 2FA prime) instead of the removed standalone trigger.
send_ready() returns True.
ZiB před 4 měsíci
rodič
revize
d50e5ee2f3

+ 0 - 1
pyproject.toml

@@ -18,7 +18,6 @@ refresh-creds = "room_cal.service_api._scripts:refresh_cookies"
 refresh-airbnb = "room_cal.service_api._scripts:refresh_airbnb_cli"
 refresh-booking = "room_cal.service_api._scripts:refresh_booking_cli"
 refresh-homestay = "room_cal.service_api._scripts:refresh_homestay_cookies"
-booking-2fa = "room_cal.service_api._scripts:_trigger_booking_chat_auth_cli"
 room-cal = "room_cal.actions._cli:main"
 room-api = "room_cal.api._server:main"
 room-mcp = "room_cal.mcp._server:main"

+ 3 - 3
room_cal/service_api/_booking.py

@@ -11,7 +11,7 @@ from urllib.parse import parse_qs, urlparse
 
 import httpx
 
-from ._scripts import refresh_booking_cookies, trigger_booking_chat_auth
+from ._scripts import refresh_booking_cookies
 from room_cal._utils import data_home, get_config, read_env
 from ._utils import month_bounds, months_in_range
 from .credentials import is_stale, mark_stale, set_credentials
@@ -660,8 +660,8 @@ class _BookingClient:
         raise
       # GraphQL ISE on send typically means the messaging 2FA session has expired.
       # Re-trigger the SMS challenge and retry once.
-      _log.warning('booking send_message ISE on attempt 1 res=%s idempotency=%s — re-triggering 2FA', reservation_id, idempotency)
-      trigger_booking_chat_auth()
+      _log.warning('booking send_message ISE on attempt 1 res=%s idempotency=%s — re-triggering credential refresh', reservation_id, idempotency)
+      refresh_booking_cookies()
       self._reload_from_env()
       raw = await self._msg_gql('postFreeTextMessage', _POST_FREE_TEXT_MUTATION, payload, attempt=2)
       msg = ((raw.get('data') or {}).get('chatMessage') or {}).get('postFreeTextMessage') or {}

+ 0 - 21
room_cal/service_api/_scripts.py

@@ -71,27 +71,6 @@ def refresh_airbnb_cookies() -> None:
   _log.info('airbnb: credential refresh complete')
 
 
-def trigger_booking_chat_auth() -> None:
-  """Run booking-2fa; raises ServiceAuthError on failure. Safe to call from within the server."""
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  _log.info('booking: starting standalone messaging 2FA prime')
-  with _booking_lock:
-    result = subprocess.run(['npm', 'run', 'booking-2fa'], cwd=ts_dir, env=_npm_env())
-  if result.returncode != 0:
-    _log.error('booking: booking-2fa failed (exit %d)', result.returncode)
-    from .types import ServiceAuthError
-    raise ServiceAuthError(f'booking-2fa failed (exit {result.returncode}) — SMS gateway may not be configured')
-  _log.info('booking: messaging 2FA prime complete')
-
-
-def _trigger_booking_chat_auth_cli() -> None:
-  """CLI entry point for `uv run booking-2fa`."""
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  result = subprocess.run(['npm', 'run', 'booking-2fa', '--'] + sys.argv[1:], cwd=ts_dir, env=_npm_env())
-  sys.exit(result.returncode)
-
 
 def refresh_service(key: str) -> None:
   """Trigger the automatic credential refresh for the named service. Raises ServiceAuthError on failure."""

+ 6 - 2
room_cal/service_api/typescript/booking/refresh-cookies.ts

@@ -238,7 +238,7 @@ 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.PRIME_CHAT_2FA;
+const SKIP_CHAT_2FA = !!process.env.SKIP_CHAT_2FA;
 
 export async function refresh(mode: string): Promise<CredentialsPayload> {
   const profile = profileDir('booking', mode);
@@ -339,7 +339,11 @@ export async function refresh(mode: string): Promise<CredentialsPayload> {
   const session = new URL(page.url()).searchParams.get('ses') ?? ses;
 
   if (!SKIP_CHAT_2FA) {
-    await primeMessagingSession(context, page, hotelId, session, smsMasked);
+    try {
+      await primeMessagingSession(context, page, hotelId, session, smsMasked);
+    } catch (err) {
+      console.error('[booking] Messaging 2FA failed (calendar credentials will still be saved):', (err as Error).message);
+    }
   }
 
   console.error('Capturing cookies...');

+ 0 - 143
room_cal/service_api/typescript/booking/trigger-2fa.ts

@@ -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); });
-}

+ 0 - 1
room_cal/service_api/typescript/package.json

@@ -5,7 +5,6 @@
   "scripts": {
     "refresh-airbnb": "tsx airbnb/refresh-cookies.ts",
     "refresh-booking": "tsx booking/refresh-cookies.ts",
-    "booking-2fa": "tsx booking/trigger-2fa.ts",
     "refresh-all": "tsx scripts/refresh-all.ts",
     "install-browsers": "tsx scripts/install-browsers.ts",
     "check-credentials": "tsx scripts/check-credentials.ts",