Browse Source

Move service-specific credential refresh into each client; expose via ServiceAPI

ZiB 4 months ago
parent
commit
9ff2b99649

+ 2 - 3
room_cal/api/_routes/credentials.py

@@ -2,7 +2,6 @@ import asyncio
 from fastapi import APIRouter, Body, HTTPException
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api.credentials import set_credentials, check_credentials
 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.service_api.types import ServiceAuthError
 from room_cal._utils import services, local_today
 from room_cal._utils import services, local_today
 from room_cal._health import probe_once
 from room_cal._health import probe_once
@@ -42,11 +41,11 @@ async def post_credentials_refresh(body: dict = Body(default={})):
   """Trigger automatic credential refresh for one service (body: {service: key}) or all services."""
   """Trigger automatic credential refresh for one service (body: {service: key}) or all services."""
   service = (body or {}).get('service')
   service = (body or {}).get('service')
   keys = [service] if service else [s.key for s in services()]
   keys = [service] if service else [s.key for s in services()]
-  loop = asyncio.get_running_loop()
+  api = ServiceAPI()
 
 
   async def _refresh(key: str) -> None:
   async def _refresh(key: str) -> None:
     try:
     try:
-      await loop.run_in_executor(None, refresh_service, key)
+      await api.refresh_credentials(key)
     except ServiceAuthError as e:
     except ServiceAuthError as e:
       raise HTTPException(status_code=500, detail=str(e))
       raise HTTPException(status_code=500, detail=str(e))
 
 

+ 3 - 3
room_cal/mcp/__init__.py

@@ -179,14 +179,14 @@ async def delete_discount(target_date: str) -> None:
 @mcp.tool()
 @mcp.tool()
 async def refresh_credentials(service: str | None = None) -> dict:
 async def refresh_credentials(service: str | None = None) -> dict:
   """Trigger automatic credential refresh via Playwright. service: optional key (e.g. 'booking', 'airbnb'); omit to refresh all. Booking.com includes messaging 2FA and may take 2+ minutes. Returns {refreshed: [...], errors: {key: reason}}."""
   """Trigger automatic credential refresh via Playwright. service: optional key (e.g. 'booking', 'airbnb'); omit to refresh all. Booking.com includes messaging 2FA and may take 2+ minutes. Returns {refreshed: [...], errors: {key: reason}}."""
-  from room_cal.service_api._scripts import refresh_service
+  from room_cal.service_api import ServiceAPI
   from room_cal.service_api.types import ServiceAuthError
   from room_cal.service_api.types import ServiceAuthError
   keys = [service] if service else [s.key for s in _services()]
   keys = [service] if service else [s.key for s in _services()]
-  loop = asyncio.get_running_loop()
+  api = ServiceAPI()
 
 
   async def _do(key: str) -> tuple[str, str | None]:
   async def _do(key: str) -> tuple[str, str | None]:
     try:
     try:
-      await loop.run_in_executor(None, refresh_service, key)
+      await api.refresh_credentials(key)
       return key, None
       return key, None
     except (ServiceAuthError, Exception) as e:
     except (ServiceAuthError, Exception) as e:
       return key, str(e)
       return key, str(e)

+ 18 - 2
room_cal/service_api/_airbnb.py

@@ -13,7 +13,7 @@ from dataclasses import dataclass
 
 
 import logging
 import logging
 
 
-from ._scripts import refresh_airbnb_cookies
+from ._scripts import _assert_not_async, _npm_env, _ts_dir, _ensure_installed
 from room_cal._utils import data_home, get_config, read_env
 from room_cal._utils import data_home, get_config, read_env
 from ._utils import month_bounds, months_in_range
 from ._utils import month_bounds, months_in_range
 from .credentials import is_stale, mark_stale
 from .credentials import is_stale, mark_stale
@@ -108,10 +108,26 @@ class _AirbnbClient:
     self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
     self._cookies = env.get('AIRBNB_COOKIES', self._cookies)
     self.clear_cache()
     self.clear_cache()
 
 
+  @staticmethod
+  def refresh_credentials() -> None:
+    _assert_not_async('refresh_credentials')
+    ts_dir = _ts_dir()
+    _ensure_installed(ts_dir)
+    import subprocess
+    _log.info('airbnb: starting credential refresh')
+    env = _npm_env()
+    env['HEADLESS'] = '1'
+    try:
+      subprocess.run(['npm', 'run', 'refresh-airbnb'], cwd=ts_dir, env=env, check=True)
+    except subprocess.CalledProcessError as e:
+      _log.error('airbnb: credential refresh failed (exit %d)', e.returncode)
+      raise ServiceAuthError(f'Airbnb session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
+    _log.info('airbnb: credential refresh complete')
+
   async def _auto_refresh(self) -> None:
   async def _auto_refresh(self) -> None:
     _log.warning('Airbnb session may be expired — launching headless Playwright')
     _log.warning('Airbnb session may be expired — launching headless Playwright')
     try:
     try:
-      await asyncio.get_running_loop().run_in_executor(None, refresh_airbnb_cookies)
+      await asyncio.get_running_loop().run_in_executor(None, _AirbnbClient.refresh_credentials)
       self._reload_from_env()
       self._reload_from_env()
     except ServiceAuthError:
     except ServiceAuthError:
       mark_stale('airbnb')
       mark_stale('airbnb')

+ 18 - 3
room_cal/service_api/_booking.py

@@ -11,7 +11,7 @@ from urllib.parse import parse_qs, urlparse
 
 
 import httpx
 import httpx
 
 
-from ._scripts import refresh_booking_cookies
+from ._scripts import _assert_not_async, _booking_lock, _npm_env, _ts_dir, _ensure_installed
 from room_cal._utils import data_home, get_config, read_env
 from room_cal._utils import data_home, get_config, read_env
 from ._utils import month_bounds, months_in_range
 from ._utils import month_bounds, months_in_range
 from .credentials import is_stale, mark_stale, set_credentials
 from .credentials import is_stale, mark_stale, set_credentials
@@ -252,6 +252,21 @@ class _BookingClient:
     except Exception:
     except Exception:
       return False
       return False
 
 
+  @staticmethod
+  def refresh_credentials() -> None:
+    _assert_not_async('refresh_credentials')
+    import subprocess
+    ts_dir = _ts_dir()
+    _ensure_installed(ts_dir)
+    _log.info('booking: starting credential refresh')
+    with _booking_lock:
+      try:
+        subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=_npm_env(), check=True)
+      except subprocess.CalledProcessError as e:
+        _log.error('booking: credential refresh failed (exit %d)', e.returncode)
+        raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
+    _log.info('booking: credential refresh complete')
+
   async def _auto_refresh(self, *, trigger: str = 'unknown') -> None:
   async def _auto_refresh(self, *, trigger: str = 'unknown') -> None:
     _log.warning('Booking.com session may be expired — trying HTTP session refresh')
     _log.warning('Booking.com session may be expired — trying HTTP session refresh')
     if await self._try_http_session_refresh():
     if await self._try_http_session_refresh():
@@ -259,7 +274,7 @@ class _BookingClient:
       return
       return
     _log.warning('HTTP refresh failed — launching headless Playwright')
     _log.warning('HTTP refresh failed — launching headless Playwright')
     try:
     try:
-      await asyncio.get_running_loop().run_in_executor(None, refresh_booking_cookies)
+      await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
       self._reload_from_env()
       self._reload_from_env()
     except ServiceAuthError:
     except ServiceAuthError:
       mark_stale('booking')
       mark_stale('booking')
@@ -659,7 +674,7 @@ class _BookingClient:
       # GraphQL ISE on send typically means the messaging 2FA session has expired.
       # GraphQL ISE on send typically means the messaging 2FA session has expired.
       # Re-trigger the SMS challenge and retry once.
       # Re-trigger the SMS challenge and retry once.
       _log.warning('booking send_message ISE on attempt 1 res=%s idempotency=%s — re-triggering credential refresh', reservation_id, idempotency)
       _log.warning('booking send_message ISE on attempt 1 res=%s idempotency=%s — re-triggering credential refresh', reservation_id, idempotency)
-      await asyncio.get_running_loop().run_in_executor(None, refresh_booking_cookies)
+      await asyncio.get_running_loop().run_in_executor(None, _BookingClient.refresh_credentials)
       self._reload_from_env()
       self._reload_from_env()
       raw = await self._msg_gql('postFreeTextMessage', _POST_FREE_TEXT_MUTATION, payload, attempt=2)
       raw = await self._msg_gql('postFreeTextMessage', _POST_FREE_TEXT_MUTATION, payload, attempt=2)
       msg = ((raw.get('data') or {}).get('chatMessage') or {}).get('postFreeTextMessage') or {}
       msg = ((raw.get('data') or {}).get('chatMessage') or {}).get('postFreeTextMessage') or {}

+ 0 - 45
room_cal/service_api/_scripts.py

@@ -54,51 +54,6 @@ def _assert_not_async(fn_name: str) -> None:
 _booking_lock = threading.Lock()
 _booking_lock = threading.Lock()
 
 
 
 
-def refresh_booking_cookies() -> None:
-  _assert_not_async('refresh_booking_cookies')
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  _log.info('booking: starting credential refresh')
-  with _booking_lock:
-    try:
-      subprocess.run(['npm', 'run', 'refresh-booking'], cwd=ts_dir, env=_npm_env(), check=True)
-    except subprocess.CalledProcessError as e:
-      _log.error('booking: credential refresh failed (exit %d)', e.returncode)
-      from .types import ServiceAuthError
-      raise ServiceAuthError(f'Booking.com credential refresh failed (exit {e.returncode})') from None
-  _log.info('booking: credential refresh complete')
-
-
-def refresh_airbnb_cookies() -> None:
-  _assert_not_async('refresh_airbnb_cookies')
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  _log.info('airbnb: starting credential refresh')
-  env = _npm_env()
-  env['HEADLESS'] = '1'
-  try:
-    subprocess.run(['npm', 'run', 'refresh-airbnb'], cwd=ts_dir, env=env, check=True)
-  except subprocess.CalledProcessError as e:
-    _log.error('airbnb: credential refresh failed (exit %d)', e.returncode)
-    from .types import ServiceAuthError
-    raise ServiceAuthError(f'Airbnb session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
-  _log.info('airbnb: credential refresh complete')
-
-
-
-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,
-  }
-  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_airbnb_cli() -> None:
 def refresh_airbnb_cli() -> None:
   """CLI entry point for `uv run refresh-airbnb`."""
   """CLI entry point for `uv run refresh-airbnb`."""
   ts_dir = _ts_dir()
   ts_dir = _ts_dir()

+ 14 - 0
room_cal/service_api/client.py

@@ -41,6 +41,20 @@ class ServiceAPI:
         _log.warning('service %s disabled: %s', key, e)
         _log.warning('service %s disabled: %s', key, e)
         self._disabled[key] = str(e)
         self._disabled[key] = str(e)
 
 
+  _REFRESH_HANDLERS: dict[str, type] = {
+    'airbnb': _AirbnbClient,
+    'booking': _BookingClient,
+  }
+
+  async def refresh_credentials(self, key: str) -> None:
+    """Trigger credential refresh for the named service. Raises ServiceAuthError on failure."""
+    cls = self._REFRESH_HANDLERS.get(key)
+    if cls is None:
+      from .types import ServiceAuthError
+      raise ServiceAuthError(f'No automatic refresh handler for service: {key}')
+    loop = asyncio.get_running_loop()
+    await loop.run_in_executor(None, cls.refresh_credentials)
+
   def client(self, key: str) -> ServiceClient:
   def client(self, key: str) -> ServiceClient:
     """Return the platform client for `key`, typed as ServiceClient."""
     """Return the platform client for `key`, typed as ServiceClient."""
     try:
     try: