Просмотр исходного кода

Wire Booking.com messaging 2FA into the main refresh flow

- refresh-cookies.ts now navigates to the messaging inbox after the
  monthly calendar step and completes any auth-assurance SMS challenge
  automatically (SKIP_CHAT_2FA=1 to bypass)
- handleAuthAssurance aligned with trigger-2fa: tries both button/link
  selectors, clears stale SMS codes before triggering, adds explicit
  "Send verification code" click
- _BookingClient.send_ready() returns True — 2FA is now primed on every
  refresh; ISE-triggered re-prime remains as fallback
- _booking_lock serializes refresh_booking_cookies and
  trigger_booking_chat_auth — one SMS code, one session, no races
- POST /credentials triggers automatic refresh for one service or all;
  runs in thread pool, probes health on completion
- trigger-2fa.ts gets a TODO noting the future structural merge
ZiB 4 месяцев назад
Родитель
Сommit
f2b7c8766f

+ 19 - 1
room_cal/api/_routes/credentials.py

@@ -1,7 +1,8 @@
 import asyncio
-from fastapi import APIRouter, Body
+from fastapi import APIRouter, Body, HTTPException
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api.credentials import set_credentials, check_credentials
+from room_cal.service_api._scripts import refresh_service
 from room_cal.service_api.types import ServiceAuthError
 from room_cal._utils import services, local_today
 from room_cal._health import probe_once
@@ -36,6 +37,23 @@ async def get_credential_status(service: str):
   return await _probe(service, api)
 
 
+@router.post('/credentials', status_code=204)
+async def post_credentials_refresh(body: dict = Body(default={})):
+  """Trigger automatic credential refresh for one service (body: {service: key}) or all services."""
+  service = (body or {}).get('service')
+  keys = [service] if service else [s.key for s in services()]
+  loop = asyncio.get_running_loop()
+
+  async def _refresh(key: str) -> None:
+    try:
+      await loop.run_in_executor(None, refresh_service, key)
+    except ServiceAuthError as e:
+      raise HTTPException(status_code=500, detail=str(e))
+
+  await asyncio.gather(*[_refresh(k) for k in keys])
+  asyncio.create_task(probe_once())
+
+
 @router.put('/credentials/{service}', status_code=204)
 async def put_credentials(service: str, credentials: dict[str, str] = Body(...)):
   set_credentials(service, credentials)

+ 1 - 2
room_cal/service_api/_booking.py

@@ -587,8 +587,7 @@ class _BookingClient:
     ]
 
   def send_ready(self) -> bool:
-    # TODO: Figure out how to test our session without a remote API call
-    return False
+    return True
 
   async def get_thread(self, reservation_id: str) -> BookingThread:
     """Fetch a conversation thread and its messages by reservation ID."""

+ 28 - 6
room_cal/service_api/_scripts.py

@@ -1,6 +1,7 @@
 import os
 import subprocess
 import sys
+import threading
 from pathlib import Path
 
 from room_cal._utils import get_config
@@ -33,14 +34,20 @@ def _ensure_installed(ts_dir: Path) -> None:
     print()
 
 
+# One SMS code is valid at a time and Booking.com rate-limits sends regardless of how often we ask,
+# so there is no benefit to concurrent booking browser operations — serialize them all.
+_booking_lock = threading.Lock()
+
+
 def refresh_booking_cookies() -> None:
   ts_dir = _ts_dir()
   _ensure_installed(ts_dir)
-  try:
-    subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=_npm_env(), check=True)
-  except subprocess.CalledProcessError as e:
-    from .types import ServiceAuthError
-    raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
+  with _booking_lock:
+    try:
+      subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=_npm_env(), check=True)
+    except subprocess.CalledProcessError as e:
+      from .types import ServiceAuthError
+      raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
 
 
 def refresh_airbnb_cookies() -> None:
@@ -70,7 +77,8 @@ 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)
-  result = subprocess.run(['npm', 'run', 'booking-2fa'], cwd=ts_dir, env=_npm_env())
+  with _booking_lock:
+    result = subprocess.run(['npm', 'run', 'booking-2fa'], cwd=ts_dir, env=_npm_env())
   if result.returncode != 0:
     from .types import ServiceAuthError
     raise ServiceAuthError(f'booking-2fa failed (exit {result.returncode}) — SMS gateway may not be configured')
@@ -84,6 +92,20 @@ def _trigger_booking_chat_auth_cli() -> None:
   sys.exit(result.returncode)
 
 
+def refresh_service(key: str) -> None:
+  """Trigger the automatic credential refresh for the named service. Raises ServiceAuthError on failure."""
+  handlers = {
+    'booking': refresh_booking_cookies,
+    'airbnb': refresh_airbnb_cookies,
+    'homestay': refresh_homestay_cookies,
+  }
+  fn = handlers.get(key)
+  if fn is None:
+    from .types import ServiceAuthError
+    raise ServiceAuthError(f'No automatic refresh handler for service: {key}')
+  fn()
+
+
 def refresh_cookies() -> None:
   ts_dir = _ts_dir()
   _ensure_installed(ts_dir)

+ 102 - 8
room_cal/service_api/typescript/booking/refresh-cookies.ts

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

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

@@ -1,3 +1,8 @@
+// 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';