_booking.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. import asyncio
  2. import json
  3. import logging
  4. import os
  5. import random
  6. import time
  7. import warnings
  8. from dataclasses import dataclass
  9. from room_cal._utils import local_today as _local_today
  10. from urllib.parse import parse_qs, urlparse
  11. import httpx
  12. from ._scripts import refresh_booking_cookies, trigger_booking_chat_auth
  13. from room_cal._utils import data_home, get_config, read_env
  14. from ._utils import month_bounds, months_in_range
  15. from .credentials import is_stale, mark_stale, set_credentials
  16. from .types import DayInfo, ServiceAuthError, Reservation
  17. _log = logging.getLogger(__name__)
  18. def _is_past(d: str) -> bool:
  19. return d < _local_today().isoformat()
  20. _BASE = 'https://admin.booking.com'
  21. _AUTH_HINT = 'refresh-creds'
  22. _UPDATE_MUTATION = """mutation updateRoomDateMutation($input: PartnerAvailabilityUpdateInput) {
  23. partnerAvailabilityMutations {
  24. partnerAvailabilityUpdate(input: $input) {
  25. success
  26. hotel {
  27. rooms {
  28. roomId
  29. dates {
  30. date
  31. hidden
  32. roomsToSell { changed value __typename }
  33. status { changed value __typename }
  34. statusReason { changed value __typename }
  35. __typename
  36. }
  37. rates {
  38. rateId
  39. restrictionAdvice { date minStayThrough __typename }
  40. dates {
  41. date status statusReason
  42. roomsToSell { changed value __typename }
  43. priceSingleUse { changed value __typename }
  44. price { occupancy value __typename }
  45. minStayThrough { changed value __typename }
  46. minStayArrival { changed value __typename }
  47. minAdvanceRes { changed value __typename }
  48. maxStayThrough { changed value __typename }
  49. maxStayArrival { changed value __typename }
  50. maxAdvanceRes { changed value __typename }
  51. lengthOfStay { changed value __typename }
  52. exactStayArrival { changed value __typename }
  53. closedOnDeparture { changed value __typename }
  54. closedOnArrival { changed value __typename }
  55. closed
  56. __typename
  57. }
  58. __typename
  59. }
  60. __typename
  61. }
  62. __typename
  63. }
  64. result {
  65. warnings { message dates code __typename }
  66. untilDate success fromDate
  67. errors { dates message __typename }
  68. __typename
  69. }
  70. __typename
  71. }
  72. __typename
  73. }
  74. }"""
  75. _CONVERSATION_MESSAGES_QUERY = """query conversationMessages($input: ChatSingleConversationInput!) {
  76. chatConversation {
  77. singleConversation(input: $input) {
  78. conversation {
  79. flags { isReplyNeeded __typename }
  80. __typename
  81. }
  82. messages {
  83. messageId created content type
  84. sender { participantType __typename }
  85. attributes {
  86. ... on SelfServiceEventDetailChatEntity {
  87. eventDetail { body __typename }
  88. __typename
  89. }
  90. }
  91. __typename
  92. }
  93. __typename
  94. }
  95. __typename
  96. }
  97. }"""
  98. _POST_FREE_TEXT_MUTATION = """mutation postFreeTextMessage($input: ChatPostFreeTextMessageInput!) {
  99. chatMessage {
  100. postFreeTextMessage(input: $input) {
  101. message {
  102. messageId created content type
  103. sender { participantType __typename }
  104. __typename
  105. }
  106. __typename
  107. }
  108. __typename
  109. }
  110. }"""
  111. class BookingAuthError(ServiceAuthError):
  112. pass
  113. @dataclass
  114. class BookingMessage:
  115. id: str
  116. created_at: str
  117. body: str | None
  118. sender: str # 'GUEST' | 'PARTNER'
  119. body_translated: str | None = None
  120. @dataclass
  121. class BookingThread:
  122. reservation_id: str
  123. messages: list[BookingMessage]
  124. is_reply_needed: bool = False
  125. def _page_view_id() -> str:
  126. return ''.join(random.choices('0123456789abcdef', k=16))
  127. class _BookingClient:
  128. def __init__(
  129. self,
  130. cookies: str,
  131. session: str,
  132. hotel_id: int,
  133. account_id: int,
  134. room_id: int,
  135. rate_id: str,
  136. csrf_token: str | None = None,
  137. lang: str = 'xu',
  138. ):
  139. self._cookies = cookies
  140. self._session = session
  141. self._hotel_id = hotel_id
  142. self._account_id = account_id
  143. self._room_id = room_id
  144. self._rate_id = rate_id
  145. self._csrf_token = csrf_token
  146. self._lang = lang
  147. self._cache: dict[str, DayInfo] = {}
  148. self._fetched_months: set[tuple[int, int]] = set()
  149. self._month_data: dict[tuple[int, int], dict[str, DayInfo]] = {}
  150. self._months_inflight: dict[tuple[int, int], asyncio.Task] = {}
  151. self._refresh_lock = asyncio.Lock()
  152. self._refresh_task: asyncio.Task | None = None
  153. @classmethod
  154. def from_env(cls) -> '_BookingClient':
  155. env_file = data_home() / 'booking.env'
  156. env = {**read_env(env_file), **os.environ}
  157. required_env = ['BOOKING_COOKIES', 'BOOKING_SESSION']
  158. missing_env = [k for k in required_env if not env.get(k)]
  159. if missing_env:
  160. raise ValueError(f'Missing in {env_file}: {", ".join(missing_env)}')
  161. svc = get_config().services['booking']
  162. missing_ids = [k for k in ('hotel_id', 'account_id', 'room_id', 'rate_id') if not getattr(svc, k)]
  163. if missing_ids:
  164. raise ValueError(f'Missing from config.json booking: {", ".join(missing_ids)}')
  165. return cls(
  166. cookies=env['BOOKING_COOKIES'],
  167. session=env['BOOKING_SESSION'],
  168. hotel_id=int(svc.hotel_id),
  169. account_id=int(svc.account_id),
  170. room_id=int(svc.room_id),
  171. rate_id=svc.rate_id,
  172. csrf_token=env.get('BOOKING_CSRF_TOKEN'),
  173. )
  174. def clear_cache(self) -> None:
  175. self._cache.clear()
  176. self._fetched_months.clear()
  177. self._month_data.clear()
  178. def _reload_from_env(self) -> None:
  179. env_file = data_home() / 'booking.env'
  180. env = {**read_env(env_file), **os.environ}
  181. self._cookies = env.get('BOOKING_COOKIES', self._cookies)
  182. self._session = env.get('BOOKING_SESSION', self._session)
  183. self._csrf_token = env.get('BOOKING_CSRF_TOKEN', self._csrf_token)
  184. self.clear_cache()
  185. def _update_csrf(self, headers: httpx.Headers) -> None:
  186. """Capture CSRF from a response header if present and not yet known."""
  187. csrf = headers.get('x-booking-csrf')
  188. if csrf and csrf != 'undefined' and csrf != self._csrf_token:
  189. self._csrf_token = csrf
  190. set_credentials('booking', {'BOOKING_CSRF_TOKEN': csrf})
  191. async def _try_http_session_refresh(self) -> bool:
  192. """GET the monthly calendar page; extract a fresh ses= from the final URL.
  193. Also captures CSRF from response headers if present.
  194. Returns True if the session is still alive, False if re-authentication is needed.
  195. """
  196. url = f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html'
  197. params = {
  198. 'hotel_id': str(self._hotel_id),
  199. 'lang': self._lang,
  200. 'ses': self._session,
  201. 'view_mode': 'MONTHLY',
  202. }
  203. headers = {
  204. 'Accept': 'text/html,application/xhtml+xml,*/*',
  205. 'Accept-Language': 'en-US,en;q=0.9',
  206. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
  207. 'Cookie': self._cookies,
  208. }
  209. try:
  210. async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client:
  211. r = await client.get(url, params=params, headers=headers)
  212. final_url = str(r.url)
  213. if 'message=ERR100' in final_url or 'account.booking.com' in final_url:
  214. return False
  215. new_ses = parse_qs(urlparse(final_url).query).get('ses', [None])[0]
  216. if new_ses:
  217. self._update_csrf(r.headers)
  218. if new_ses != self._session:
  219. self._session = new_ses
  220. set_credentials('booking', {'BOOKING_SESSION': new_ses})
  221. self.clear_cache()
  222. return True
  223. return False
  224. except Exception:
  225. return False
  226. async def _auto_refresh(self, *, trigger: str = 'unknown') -> None:
  227. _log.warning('Booking.com session may be expired — trying HTTP session refresh')
  228. if await self._try_http_session_refresh():
  229. _log.info('Booking.com session refreshed via HTTP')
  230. return
  231. _log.warning('HTTP refresh failed — launching headless Playwright')
  232. try:
  233. refresh_booking_cookies()
  234. self._reload_from_env()
  235. except ServiceAuthError:
  236. mark_stale('booking')
  237. raise
  238. async def _ensure_refreshed(self, *, trigger: str) -> None:
  239. async with self._refresh_lock:
  240. if self._refresh_task is None or self._refresh_task.done():
  241. self._refresh_task = asyncio.create_task(self._auto_refresh(trigger=trigger))
  242. task = self._refresh_task
  243. await task
  244. def _actor(self) -> dict:
  245. return {'actorId': str(self._hotel_id), 'actorType': 'HOTEL'}
  246. def _common_params(self, extra: dict | None = None) -> dict[str, str]:
  247. p = {
  248. 'ses': self._session,
  249. 'hotel_id': str(self._hotel_id),
  250. 'lang': self._lang,
  251. 'hotel_account_id': str(self._account_id),
  252. }
  253. if extra:
  254. p.update(extra)
  255. return p
  256. def _messaging_referer(self) -> str:
  257. return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/messaging/inbox.html'
  258. f'?hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}&source=nav&tlc=1')
  259. def _monthly_referer(self) -> str:
  260. return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/monthly.html'
  261. f'?hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}&view_mode=MONTHLY')
  262. def _matrix_referer(self) -> str:
  263. return (f'{_BASE}/hotel/hoteladmin/extranet_ng/manage/calendar/index.html'
  264. f'?view_mode=MATRIX&hotel_id={self._hotel_id}&lang={self._lang}&ses={self._session}')
  265. def _rest_headers(self, referer: str, extra: dict | None = None) -> dict[str, str]:
  266. h = {
  267. 'Accept': 'application/json, text/plain, */*',
  268. 'Accept-Language': 'en-US,en;q=0.9',
  269. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
  270. 'Origin': _BASE,
  271. 'Referer': referer,
  272. 'Sec-Fetch-Mode': 'cors',
  273. 'Sec-Fetch-Dest': 'empty',
  274. 'Sec-Fetch-Site': 'same-origin',
  275. 'X-Booking-SiteType-Id': '31',
  276. 'X-Booking-Language-Code': 'en-us',
  277. 'X-Booking-Pageview-Id': _page_view_id(),
  278. 'Cookie': self._cookies,
  279. }
  280. if extra:
  281. h.update(extra)
  282. return h
  283. def _gql_headers(self, referer: str) -> dict[str, str]:
  284. return {
  285. 'Accept': '*/*',
  286. 'Accept-Language': 'en-US,en;q=0.9',
  287. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
  288. 'Origin': _BASE,
  289. 'Referer': referer,
  290. 'Sec-Fetch-Mode': 'cors',
  291. 'Sec-Fetch-Dest': 'empty',
  292. 'Sec-Fetch-Site': 'same-origin',
  293. 'x-booking-site-type-id': '31',
  294. 'x-booking-context-action': 'hhemc_index',
  295. 'x-booking-context-action-name': 'hhemc_index',
  296. 'X-Booking-Pageview-ID': _page_view_id(),
  297. 'x-booking-csrf-token': 'undefined',
  298. 'apollographql-client-name': 'b-partner-availability-extranet-mfe',
  299. 'apollographql-client-version': 'EQSXMHMY',
  300. 'x-envoy-expected-rq-timeout-ms': '15000',
  301. 'x-booking-timeout-budget-ms': '15000',
  302. 'X-Booking-Topic': 'capla_browser_b-partner-availability-extranet-mfe',
  303. 'Cookie': self._cookies,
  304. }
  305. def _msg_headers(self) -> dict[str, str]:
  306. return {
  307. 'Accept': '*/*',
  308. 'Accept-Language': 'en-US,en;q=0.9',
  309. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
  310. 'Origin': _BASE,
  311. 'Referer': self._messaging_referer(),
  312. 'Sec-Fetch-Mode': 'cors',
  313. 'Sec-Fetch-Dest': 'empty',
  314. 'Sec-Fetch-Site': 'same-origin',
  315. 'x-booking-site-type-id': '31',
  316. 'x-booking-context-action': 'hhemm_inbox',
  317. 'x-booking-context-action-name': 'hhemm_inbox',
  318. 'X-Booking-Pageview-ID': _page_view_id(),
  319. 'x-booking-csrf-token': 'undefined',
  320. 'X-Booking-Topic': 'capla_browser_b-partner-messaging-extranet-mfe',
  321. 'apollographql-client-name': 'b-partner-messaging-extranet-mfe',
  322. 'apollographql-client-version': 'ABJaNSUc',
  323. 'Cookie': self._cookies,
  324. }
  325. async def _msg_gql(self, operation_name: str, query: str, variables: dict) -> dict:
  326. params = {
  327. 'hotel_id': str(self._hotel_id),
  328. 'lang': self._lang,
  329. 'ses': self._session,
  330. 'source': 'nav',
  331. 'tlc': '1',
  332. }
  333. async def _attempt() -> httpx.Response:
  334. async with httpx.AsyncClient() as client:
  335. return await client.post(
  336. f'{_BASE}/dml/graphql.json',
  337. params=params,
  338. json={'operationName': operation_name, 'variables': variables, 'query': query},
  339. headers=self._msg_headers(),
  340. )
  341. try:
  342. r = await _attempt()
  343. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  344. raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
  345. if r.status_code in (400, 401, 403):
  346. await self._ensure_refreshed(trigger='messaging')
  347. r = await _attempt()
  348. await self._check_auth(r)
  349. r.raise_for_status()
  350. raw = r.json()
  351. if raw.get('errors'):
  352. msgs = ', '.join(e.get('message', '') for e in raw['errors'])
  353. raise RuntimeError(f'GraphQL error: {msgs}')
  354. return raw
  355. async def _check_auth(self, r: httpx.Response) -> None:
  356. if r.status_code in (400, 401, 403):
  357. mark_stale('booking')
  358. raise BookingAuthError(f'Booking.com session expired (HTTP {r.status_code}) — run: {_AUTH_HINT}')
  359. async def _multipart_post(self, url: str, referer: str, body: dict, extra_headers: dict | None = None) -> dict:
  360. async def _attempt() -> httpx.Response:
  361. headers = self._rest_headers(referer, extra_headers)
  362. async with httpx.AsyncClient() as client:
  363. return await client.post(url, params=self._common_params(),
  364. files={'request': (None, json.dumps(body))}, headers=headers)
  365. try:
  366. r = await _attempt()
  367. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  368. raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
  369. if r.status_code in (400, 401, 403):
  370. await self._ensure_refreshed(trigger='inventory/fetch')
  371. r = await _attempt()
  372. await self._check_auth(r)
  373. r.raise_for_status()
  374. self._update_csrf(r.headers)
  375. return r.json()
  376. async def _gql_mutate(self, variables: dict) -> dict:
  377. async def _attempt() -> httpx.Response:
  378. referer = self._matrix_referer()
  379. params = {'view_mode': 'MATRIX', 'hotel_id': str(self._hotel_id),
  380. 'lang': self._lang, 'ses': self._session}
  381. async with httpx.AsyncClient() as client:
  382. return await client.post(
  383. f'{_BASE}/dml/graphql.json',
  384. params=params,
  385. json={'operationName': 'updateRoomDateMutation', 'variables': variables,
  386. 'extensions': {}, 'query': _UPDATE_MUTATION},
  387. headers=self._gql_headers(referer),
  388. )
  389. try:
  390. r = await _attempt()
  391. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  392. raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
  393. if r.status_code in (400, 401, 403):
  394. await self._ensure_refreshed(trigger='graphql')
  395. r = await _attempt()
  396. await self._check_auth(r)
  397. r.raise_for_status()
  398. raw = r.json()
  399. if raw.get('errors'):
  400. msgs = ', '.join(e.get('message', '') for e in raw['errors'])
  401. raise RuntimeError(f'GraphQL error: {msgs}')
  402. result = (raw.get('data', {}).get('partnerAvailabilityMutations', {})
  403. .get('partnerAvailabilityUpdate', {}))
  404. if not result.get('success'):
  405. errs = ', '.join(e.get('message', '') for e in (result.get('result', {}).get('errors') or []))
  406. raise RuntimeError(f'updateRoomDateMutation failed{": " + errs if errs else ""}')
  407. return raw
  408. def _parse_calendar_days(self, raw: dict) -> dict[str, DayInfo]:
  409. hotel_data = raw.get('data', {}).get('hotel', {}).get(str(self._hotel_id), {})
  410. room_data = hotel_data.get('rooms', {}).get(str(self._room_id), {})
  411. rate_dates = room_data.get('rates', {}).get(str(self._rate_id), {}).get('dates', {})
  412. result: dict[str, DayInfo] = {}
  413. room_level_dates = room_data.get('dates', {})
  414. if room_level_dates:
  415. # Legacy backend: room-level dates include hidden/net_booked/rooms_to_sell
  416. for d, info in room_level_dates.items():
  417. price_str = rate_dates.get(d, {}).get('price', {}).get('2')
  418. result[d] = DayInfo(
  419. date=d,
  420. price=round(float(price_str)) if price_str else None,
  421. available=(info.get('hidden', 0) == 0),
  422. booked=(info.get('net_booked', 0) > 0),
  423. rooms_to_sell=info.get('rooms_to_sell', 0),
  424. )
  425. else:
  426. # Partner availability API: only rate-level dates; status encodes availability
  427. # "restricted" = open, "closed" = unavailable, "soldout" = booked
  428. for d, rate_info in rate_dates.items():
  429. price_str = rate_info.get('price', {}).get('2')
  430. status = rate_info.get('status', '')
  431. result[d] = DayInfo(
  432. date=d,
  433. price=round(float(price_str)) if price_str else None,
  434. available=(status == 'restricted'),
  435. booked=(status == 'soldout'),
  436. rooms_to_sell=None,
  437. )
  438. return result
  439. async def _fetch_month_calendar(self, month: int, year: int) -> dict[str, DayInfo]:
  440. start, end = month_bounds(month, year)
  441. if _is_past(end):
  442. warnings.warn(f'Booking.com: skipping calendar fetch for past month {year}-{month:02d}', stacklevel=3)
  443. return {}
  444. body = {
  445. 'dates': {'range': True, 'dates': [start, end]},
  446. 'hotel': {
  447. 'fields': ['rooms', 'policygroups'],
  448. 'rooms': {
  449. 'id': [self._room_id],
  450. 'fields': ['status', 'rooms_to_sell', 'permissions', 'rates', 'nr_rooms', 'net_booked', 'num_guests'],
  451. 'rates': {'fields': ['permissions', 'price', 'restrictions', 'status', 'rooms_to_sell',
  452. 'policygroup_id', 'policy_overrides', 'default_policygroup_id', 'name']},
  453. },
  454. 'policy': {'fields': ['info']},
  455. },
  456. 'post_process_data_plugins': ['format_price', 'multi_units_check', 'last_update', 'check_dynamic_restrictions'],
  457. }
  458. raw = await self._multipart_post(
  459. f'{_BASE}/fresa/extranet/inventory/fetch', self._monthly_referer(), body
  460. )
  461. result = self._parse_calendar_days(raw)
  462. if not result or not any(info.price is not None for info in result.values()):
  463. _log.warning('Booking.com: empty or priceless calendar for %d-%02d; retrying', year, month)
  464. await asyncio.sleep(1)
  465. raw = await self._multipart_post(
  466. f'{_BASE}/fresa/extranet/inventory/fetch', self._monthly_referer(), body
  467. )
  468. result = self._parse_calendar_days(raw)
  469. if not result or not any(info.price is not None for info in result.values()):
  470. _log.warning('Booking.com: still empty/priceless calendar for %d-%02d after retry', year, month)
  471. return result
  472. async def _get_month_calendar(self, month: int, year: int) -> dict[str, DayInfo]:
  473. key = (month, year)
  474. if key in self._month_data:
  475. return self._month_data[key]
  476. if key in self._months_inflight:
  477. return await self._months_inflight[key]
  478. task = asyncio.create_task(self._fetch_month_calendar(month, year))
  479. self._months_inflight[key] = task
  480. try:
  481. result = await task
  482. finally:
  483. self._months_inflight.pop(key, None)
  484. self._month_data[key] = result
  485. return result
  486. async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
  487. if is_stale('booking'):
  488. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  489. month_list = months_in_range(date_from, date_to)
  490. all_results = await asyncio.gather(*[self._get_month_calendar(m, y) for m, y in month_list])
  491. for month_result in all_results:
  492. self._cache.update(month_result)
  493. return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
  494. async def probe(self) -> None:
  495. from datetime import date
  496. today = date.today().isoformat()
  497. await self.get_calendar(today, today)
  498. async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
  499. if is_stale('booking'):
  500. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  501. async def _attempt() -> httpx.Response:
  502. referer = self._monthly_referer()
  503. params = self._common_params({
  504. 'from': date_from,
  505. 'to': date_to,
  506. 'room_id': str(self._room_id),
  507. 'only_basic_info': '1',
  508. })
  509. async with httpx.AsyncClient() as client:
  510. return await client.post(
  511. f'{_BASE}/fresa/extranet/calendar/get_reservations',
  512. params=params,
  513. headers=self._rest_headers(referer),
  514. )
  515. try:
  516. r = await _attempt()
  517. except (httpx.TransportError, OSError, ExceptionGroup) as e:
  518. raise BookingAuthError(f'Booking.com connection failed — session may be expired, run: {_AUTH_HINT}') from e
  519. if r.status_code in (400, 401, 403):
  520. await self._ensure_refreshed(trigger='get_reservations')
  521. r = await _attempt()
  522. await self._check_auth(r)
  523. r.raise_for_status()
  524. raw = r.json()
  525. reservations = raw.get('data', {}).get('reservations') or {}
  526. return [
  527. Reservation(
  528. service='booking',
  529. reservation_id=str(rid),
  530. check_in=r_data.get('checkin', ''),
  531. check_out=r_data.get('checkout', ''),
  532. guest_name=(r_data.get('show_name')
  533. or (f"{r_data['firstname']} {r_data['lastname']}"
  534. if r_data.get('firstname') and r_data.get('lastname')
  535. else None)),
  536. chat_id=str(rid),
  537. )
  538. for rid, r_data in reservations.items()
  539. ]
  540. def send_ready(self) -> bool:
  541. return True
  542. async def get_thread(self, reservation_id: str) -> BookingThread:
  543. """Fetch a conversation thread and its messages by reservation ID."""
  544. if is_stale('booking'):
  545. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  546. raw = await self._msg_gql(
  547. 'conversationMessages', _CONVERSATION_MESSAGES_QUERY,
  548. {'input': {
  549. 'actor': self._actor(),
  550. 'conversationReference': {
  551. 'productId': reservation_id,
  552. 'productType': 'POST_BOOKING',
  553. 'verticalType': 'ACCOMMODATION',
  554. },
  555. 'pagination': {'count': 50},
  556. }},
  557. )
  558. data = raw.get('data', {}).get('chatConversation', {}).get('singleConversation', {})
  559. is_reply_needed = ((data.get('conversation') or {}).get('flags') or {}).get('isReplyNeeded', False)
  560. messages = []
  561. for m in data.get('messages', []):
  562. msg_type = m.get('type')
  563. content = m.get('content')
  564. attrs = m.get('attributes') or {}
  565. event_body = (attrs.get('eventDetail') or {}).get('body')
  566. if msg_type == 'FREE_TEXT':
  567. body, body_translated = content, None
  568. elif event_body is not None:
  569. body, body_translated = content, event_body
  570. else:
  571. continue # system message with no displayable text
  572. messages.append(BookingMessage(
  573. id=m['messageId'],
  574. created_at=m['created'],
  575. body=body,
  576. sender=(m.get('sender') or {}).get('participantType', ''),
  577. body_translated=body_translated,
  578. ))
  579. return BookingThread(reservation_id=reservation_id, messages=messages, is_reply_needed=is_reply_needed)
  580. async def send_message(self, reservation_id: str, body: str) -> None:
  581. """Send a text message to the guest conversation for a reservation."""
  582. if is_stale('booking'):
  583. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  584. idempotency = f'{reservation_id}-{int(time.time() * 1000)}'
  585. payload = {'input': {
  586. 'actor': self._actor(),
  587. 'conversationReference': {
  588. 'productId': reservation_id,
  589. 'productType': 'POST_BOOKING',
  590. 'verticalType': 'ACCOMMODATION',
  591. },
  592. 'idempotency': idempotency,
  593. 'content': body,
  594. 'attachmentIds': [],
  595. }}
  596. try:
  597. await self._msg_gql('postFreeTextMessage', _POST_FREE_TEXT_MUTATION, payload)
  598. except RuntimeError as e:
  599. if 'Internal Server Error' not in str(e):
  600. raise
  601. # GraphQL ISE on send typically means the messaging 2FA session has expired.
  602. # Re-trigger the SMS challenge and retry once.
  603. _log.warning('Booking.com send_message failed with ISE — re-triggering 2FA')
  604. trigger_booking_chat_auth()
  605. self._reload_from_env()
  606. await self._msg_gql('postFreeTextMessage', _POST_FREE_TEXT_MUTATION, payload)
  607. async def set_price(self, d: str, price: int, occupancy: int = 2) -> None:
  608. price = int(round(price))
  609. if is_stale('booking'):
  610. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  611. if _is_past(d):
  612. warnings.warn(f'Booking.com: skipping set_price for past date {d}', stacklevel=2)
  613. return
  614. if (cached := self._cache.get(d)) and cached.price == price:
  615. return
  616. if not self._csrf_token:
  617. raise ValueError('BOOKING_CSRF_TOKEN not available — call get_calendar() first, or run refresh-creds')
  618. price_str = str(price)
  619. body = {
  620. 'update': [{
  621. 'room_id': self._room_id,
  622. 'from_date': d,
  623. 'until_date': d,
  624. 'limit_to_weekdays': [0, 1, 2, 3, 4, 5, 6],
  625. 'rate_id': self._rate_id,
  626. 'field_name': 'price',
  627. 'field_value': price_str,
  628. 'occupancy': occupancy,
  629. }],
  630. 'modified_fields': {'price_changed': 1, 'price_changed_value': price_str,
  631. 'date_selection_mode': 'CELLS'},
  632. 'post_process_data_plugins': ['format_price', 'last_update'],
  633. 'update_source': {'product': 'calendar', 'extra_data': {'new_calendar': 1, 'location': 'input'}},
  634. 'status_reason': 1,
  635. 'prevent_acav': '1',
  636. 'check_adding_av_to_closed_property': '0',
  637. }
  638. await self._multipart_post(
  639. f'{_BASE}/fresa/extranet/inventory/update', self._monthly_referer(), body,
  640. extra_headers={'X-Booking-CSRF': self._csrf_token},
  641. )
  642. self.clear_cache()
  643. _log.info('booking set_price date=%s price=$%d', d, price)
  644. async def set_availability(self, d: str, available: bool) -> None:
  645. if is_stale('booking'):
  646. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  647. if _is_past(d):
  648. warnings.warn(f'Booking.com: skipping set_availability for past date {d}', stacklevel=2)
  649. return
  650. expected_rts = 1 if available else 0
  651. if (cached := self._cache.get(d)) and cached.available == available and cached.rooms_to_sell == expected_rts:
  652. return
  653. await self._gql_mutate({'input': {
  654. 'checkAddingAVToClosedProperty': False,
  655. 'hotelId': self._hotel_id,
  656. 'postProcessDataPlugins': ['mlos_status_reason'],
  657. 'preventAcav': True,
  658. 'statusReason': True,
  659. 'updateAvailability': [{
  660. 'fromDate': d,
  661. 'roomId': self._room_id,
  662. 'untilDate': d,
  663. 'hidden': not available,
  664. 'additionalChecks': {'alertMode': 'WARN'},
  665. }],
  666. }})
  667. if not available:
  668. await self.set_rooms_to_sell(d, 0)
  669. else:
  670. cal = await self.get_calendar(d, d)
  671. if not cal.get(d, DayInfo(date=d, price=None, available=True, booked=False, rooms_to_sell=None)).booked:
  672. await self.set_rooms_to_sell(d, 1)
  673. self.clear_cache()
  674. _log.info('booking set_availability date=%s available=%s', d, available)
  675. async def set_rooms_to_sell(self, d: str, count: int) -> None:
  676. if is_stale('booking'):
  677. raise BookingAuthError(f'Booking.com credentials are stale — run: {_AUTH_HINT}')
  678. if _is_past(d):
  679. warnings.warn(f'Booking.com: skipping set_rooms_to_sell for past date {d}', stacklevel=2)
  680. return
  681. entry: dict = {
  682. 'fromDate': d,
  683. 'roomId': self._room_id,
  684. 'untilDate': d,
  685. 'roomsToSell': count,
  686. }
  687. if count > 0:
  688. entry['additionalChecks'] = {'alertMode': 'WARN'}
  689. await self._gql_mutate({'input': {
  690. 'checkAddingAVToClosedProperty': False,
  691. 'hotelId': self._hotel_id,
  692. 'postProcessDataPlugins': ['mlos_status_reason'],
  693. 'preventAcav': True,
  694. 'statusReason': True,
  695. 'updateAvailability': [entry],
  696. }})
  697. self.clear_cache()