booking-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,494 @@
1
+ """Live USP backend for production paths.
2
+
3
+ Delegates registry search to a real USP registry, profile resolution to
4
+ USPProfileResolver (live HTTP only), and all business-level operations to
5
+ USPBusinessRestClient. No local booking cache.
6
+ """
7
+ import asyncio
8
+ import logging
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ import httpx
12
+
13
+ from ..config import load_config
14
+ from ..discovery import USPBusinessProfile, USPProfileResolver
15
+ from ..http_retry import is_rate_limited
16
+ from ..merchant_pacing import (
17
+ discovery_concurrency,
18
+ merchant_host_from_url,
19
+ paced_merchant_host_request,
20
+ )
21
+ from ..models.platform import ServiceSearchIntent, ServiceSearchResult
22
+ from ..models.wire import (
23
+ AvailabilityResult,
24
+ Booking,
25
+ Hold,
26
+ TimeSlot,
27
+ USPPaymentResult,
28
+ USPService,
29
+ )
30
+ from ..rest_client import (
31
+ USPBusinessRestClient,
32
+ USPRegistryRestClient,
33
+ USPTokenStore,
34
+ )
35
+ from ..headless import attach_registry_hit, mark_live_catalog_service, service_from_registry_hit
36
+ from ..authorization import BookingScopedCredential, evaluate, resolve_posture
37
+ from ..pop import PopKey, PopKeyStore
38
+ from ..ucp_profile import UcpNativeContext, fetch_and_resolve_business_profile
39
+ from . import USPBackend
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ DEFAULT_REGISTRY_SEARCH_LIMIT = 5
44
+
45
+
46
+ class LiveUSPBackend(USPBackend):
47
+ """Production backend that talks to real USP endpoints."""
48
+
49
+ def __init__(self) -> None:
50
+ cfg = load_config()
51
+ self._cfg = cfg
52
+ self._resolver = USPProfileResolver()
53
+ self._agent_profile_url = cfg.usp_agent_profile_url
54
+ self._token_store = USPTokenStore(cfg.usp_platform_name, cfg.usp_platform_contact)
55
+ self._posture = resolve_posture(cfg.usp_auth_scoped_credential)
56
+ self._pop_keys = PopKeyStore()
57
+ self.profile_cache: Dict[str, USPBusinessProfile] = {}
58
+ self.ucp_native_by_business_id: Dict[str, UcpNativeContext] = {}
59
+ self.ucp_native_by_profile_url: Dict[str, UcpNativeContext] = {}
60
+
61
+ async def _fetch_and_resolve_profile(
62
+ self, profile_url: str,
63
+ ) -> Tuple[USPBusinessProfile, Optional[UcpNativeContext]]:
64
+ return await fetch_and_resolve_business_profile(profile_url)
65
+
66
+ async def _client(
67
+ self,
68
+ profile: USPBusinessProfile,
69
+ *,
70
+ ucp_native: bool = False,
71
+ pop_key_endpoint: Optional[str] = None,
72
+ ) -> USPBusinessRestClient:
73
+ endpoint = profile.rest_endpoint
74
+ auth_header = self._token_store.get_auth_header_name(endpoint)
75
+ bearer: Optional[str] = None
76
+ if self._token_store.has_stored_credentials(endpoint):
77
+ bearer = await self._token_store.get_token(endpoint, allow_discovery=True)
78
+
79
+ # Negotiate the mechanism from the business's published policy.
80
+ #
81
+ # ``defer=True`` because this one client serves public operations too.
82
+ # A business that publishes no usable policy must still be *browsable*
83
+ # -- catalog and availability MAY remain unauthenticated -- so the
84
+ # refusal is carried on the decision and enforced when a privileged
85
+ # request is actually attempted, not here.
86
+ decision = evaluate(
87
+ profile.authorization,
88
+ posture=self._posture,
89
+ exposes_privileged_capabilities=profile.exposes_privileged_capabilities,
90
+ business_label=profile.business.name,
91
+ defer=True,
92
+ )
93
+ pop_key: Optional[PopKey] = None
94
+ if decision.uses_pop and endpoint:
95
+ if pop_key_endpoint:
96
+ # A credential may be issued on a related UCP binding and then
97
+ # presented to the USP booking resource. Reuse that issuer key;
98
+ # generating a key for the request origin would not match cnf.jkt.
99
+ pop_key = self._pop_keys.peek(pop_key_endpoint)
100
+ else:
101
+ # First contact generates the key locally. There is no registration
102
+ # round trip: this is the whole of platform onboarding.
103
+ pop_key = self._pop_keys.get_or_create(endpoint)
104
+
105
+ return USPBusinessRestClient(
106
+ profile,
107
+ agent_profile_url=self._agent_profile_url,
108
+ bearer_token=bearer,
109
+ auth_header_name=auth_header,
110
+ token_store=self._token_store,
111
+ rest_endpoint=endpoint,
112
+ pop_key=pop_key,
113
+ authenticate=decision.authenticate,
114
+ ucp_native=ucp_native,
115
+ decision=decision,
116
+ )
117
+
118
+ @staticmethod
119
+ def _log_profile_failure(profile_url: str, exc: BaseException) -> None:
120
+ if is_rate_limited(exc):
121
+ logger.warning(
122
+ "Profile resolution rate limited (HTTP 429) for %s",
123
+ profile_url,
124
+ )
125
+ return
126
+ if isinstance(exc, httpx.HTTPStatusError):
127
+ logger.warning(
128
+ "Profile resolution HTTP %s for %s",
129
+ exc.response.status_code,
130
+ profile_url,
131
+ )
132
+ return
133
+ logger.warning("Profile resolution failed for %s", profile_url)
134
+
135
+ @staticmethod
136
+ def _log_service_failure(service_id: str, exc: BaseException) -> None:
137
+ if is_rate_limited(exc):
138
+ logger.warning(
139
+ "Service catalog fetch rate limited (HTTP 429) for %s",
140
+ service_id,
141
+ )
142
+ return
143
+ if isinstance(exc, httpx.HTTPStatusError):
144
+ logger.warning(
145
+ "Service catalog fetch HTTP %s for %s",
146
+ exc.response.status_code,
147
+ service_id,
148
+ )
149
+ return
150
+ logger.warning("Service fetch failed: %s", service_id)
151
+
152
+ # -- Registry-level ------------------------------------------------------
153
+
154
+ async def _resolve_profile_for_discovery(
155
+ self,
156
+ biz_id: str,
157
+ profile_url: str,
158
+ profile_unavailable: set[str],
159
+ ) -> None:
160
+ from booking_agent.profile_cache import read_cached_profile
161
+
162
+ if biz_id in self.profile_cache or biz_id in profile_unavailable:
163
+ return
164
+
165
+ cached = read_cached_profile(profile_url)
166
+ if cached is not None:
167
+ prof, uctx = cached
168
+ self.profile_cache[biz_id] = prof
169
+ if uctx is not None:
170
+ self.ucp_native_by_business_id[biz_id] = uctx
171
+ self.ucp_native_by_profile_url[profile_url] = uctx
172
+ return
173
+
174
+ host = merchant_host_from_url(profile_url)
175
+ try:
176
+ async with paced_merchant_host_request(host):
177
+ prof, uctx = await self._fetch_and_resolve_profile(profile_url)
178
+ self.profile_cache[biz_id] = prof
179
+ if uctx is not None:
180
+ self.ucp_native_by_business_id[biz_id] = uctx
181
+ self.ucp_native_by_profile_url[profile_url] = uctx
182
+ except Exception as exc:
183
+ profile_unavailable.add(biz_id)
184
+ self._log_profile_failure(profile_url, exc)
185
+ if is_rate_limited(exc):
186
+ raise
187
+
188
+ async def search_registry(self, intent: ServiceSearchIntent) -> List[USPService]:
189
+ if not self._cfg.usp_registry_url:
190
+ raise RuntimeError(
191
+ "USP_REGISTRY_URL is not configured; cannot search the live registry"
192
+ )
193
+ registry_client = USPRegistryRestClient()
194
+ from booking_agent.step_timing import timed_step
195
+
196
+ async with timed_step("discover"):
197
+ results, _paging = await registry_client.search_registry_for_services(
198
+ registry_url=self._cfg.usp_registry_url,
199
+ query=intent.query,
200
+ location=intent.location,
201
+ verticals=intent.verticals or None,
202
+ categories=intent.categories or None,
203
+ price_range=intent.price_range,
204
+ duration_range=intent.duration_range,
205
+ desired_service_time_ranges=intent.desired_service_time_ranges,
206
+ prefer_sooner_availability_slots=intent.prefer_sooner_availability_slots,
207
+ limit=DEFAULT_REGISTRY_SEARCH_LIMIT,
208
+ )
209
+ if len(results) > DEFAULT_REGISTRY_SEARCH_LIMIT:
210
+ logger.info(
211
+ "Registry returned %d hits; truncating to client limit %d",
212
+ len(results),
213
+ DEFAULT_REGISTRY_SEARCH_LIMIT,
214
+ )
215
+ results = results[:DEFAULT_REGISTRY_SEARCH_LIMIT]
216
+
217
+ profile_unavailable: set[str] = set()
218
+ profile_tasks: Dict[str, asyncio.Task[None]] = {}
219
+ profile_tasks_lock = asyncio.Lock()
220
+ sem = asyncio.Semaphore(discovery_concurrency())
221
+ rate_limited_exc: Optional[BaseException] = None
222
+
223
+ async def ensure_profile(biz_id: str, profile_url: str) -> Optional[USPBusinessProfile]:
224
+ if biz_id in profile_unavailable:
225
+ return None
226
+ if biz_id in self.profile_cache:
227
+ return self.profile_cache[biz_id]
228
+
229
+ async with profile_tasks_lock:
230
+ if biz_id in profile_unavailable:
231
+ return None
232
+ if biz_id in self.profile_cache:
233
+ return self.profile_cache[biz_id]
234
+ if biz_id not in profile_tasks:
235
+ profile_tasks[biz_id] = asyncio.create_task(
236
+ self._resolve_profile_for_discovery(
237
+ biz_id, profile_url, profile_unavailable,
238
+ ),
239
+ )
240
+ task = profile_tasks[biz_id]
241
+
242
+ await task
243
+ if biz_id in profile_unavailable:
244
+ return None
245
+ return self.profile_cache.get(biz_id)
246
+
247
+ async def enrich_hit(rank: int, r: ServiceSearchResult) -> Optional[USPService]:
248
+ nonlocal rate_limited_exc
249
+ if rate_limited_exc is not None:
250
+ return None
251
+ if not r.service_id or not r.business or not r.business.id:
252
+ logger.warning(
253
+ "Skipping incomplete search result: %s",
254
+ r.model_dump(exclude_none=True),
255
+ )
256
+ return None
257
+
258
+ async with sem:
259
+ if rate_limited_exc is not None:
260
+ return None
261
+ biz_id = r.business.id
262
+ profile_url = r.business.usp_profile_url
263
+ try:
264
+ prof = await ensure_profile(biz_id, profile_url)
265
+ except Exception as exc:
266
+ if is_rate_limited(exc):
267
+ rate_limited_exc = exc
268
+ raise
269
+
270
+ if prof is None:
271
+ return attach_registry_hit(
272
+ service_from_registry_hit(r), r, rank=rank,
273
+ )
274
+
275
+ catalog_host = (
276
+ merchant_host_from_url(prof.rest_endpoint)
277
+ if prof.rest_endpoint
278
+ else merchant_host_from_url(profile_url)
279
+ )
280
+ try:
281
+ async with paced_merchant_host_request(catalog_host):
282
+ svc = await self.get_service(prof, r.service_id)
283
+ svc.business = r.business
284
+ return attach_registry_hit(
285
+ mark_live_catalog_service(svc), r, rank=rank,
286
+ )
287
+ except Exception as exc:
288
+ self._log_service_failure(r.service_id, exc)
289
+ if is_rate_limited(exc):
290
+ rate_limited_exc = exc
291
+ raise
292
+ return attach_registry_hit(
293
+ service_from_registry_hit(r), r, rank=rank,
294
+ )
295
+
296
+ services: List[USPService] = []
297
+ async with timed_step("profile_resolve"):
298
+ enrich_tasks = [
299
+ asyncio.create_task(enrich_hit(rank, r))
300
+ for rank, r in enumerate(results)
301
+ ]
302
+ try:
303
+ outcomes = await asyncio.gather(*enrich_tasks, return_exceptions=True)
304
+ except Exception:
305
+ for task in enrich_tasks:
306
+ if not task.done():
307
+ task.cancel()
308
+ await asyncio.gather(*enrich_tasks, return_exceptions=True)
309
+ raise
310
+
311
+ if rate_limited_exc is not None:
312
+ for task in enrich_tasks:
313
+ if not task.done():
314
+ task.cancel()
315
+ await asyncio.gather(*enrich_tasks, return_exceptions=True)
316
+ raise rate_limited_exc
317
+
318
+ for outcome in outcomes:
319
+ if isinstance(outcome, BaseException):
320
+ if is_rate_limited(outcome):
321
+ for task in enrich_tasks:
322
+ if not task.done():
323
+ task.cancel()
324
+ await asyncio.gather(*enrich_tasks, return_exceptions=True)
325
+ raise outcome
326
+ logger.warning("Discovery enrich task failed: %s", outcome)
327
+ continue
328
+ if outcome is not None:
329
+ services.append(outcome)
330
+
331
+ services.sort(
332
+ key=lambda svc: (svc.metadata or {}).get("search_rank", 10**9),
333
+ )
334
+ return services
335
+
336
+ # -- Discovery -----------------------------------------------------------
337
+
338
+ async def resolve_profile(self, usp_profile_url: str) -> USPBusinessProfile:
339
+ prof, uctx = await self._fetch_and_resolve_profile(usp_profile_url)
340
+ if uctx is not None:
341
+ self.ucp_native_by_profile_url[usp_profile_url] = uctx
342
+ return prof
343
+
344
+ async def resolve_profile_from_agent_card(self, agent_card_url: str) -> USPBusinessProfile:
345
+ base_url = USPProfileResolver.extract_base_url_from_agent_card_url(agent_card_url)
346
+ return await self._resolver.resolve(base_url)
347
+
348
+ # -- Catalog -------------------------------------------------------------
349
+
350
+ async def list_services(
351
+ self,
352
+ profile: USPBusinessProfile,
353
+ filters: Optional[Dict[str, Any]] = None,
354
+ ) -> List[USPService]:
355
+ client = await self._client(profile)
356
+ return await client.list_services(filters=filters)
357
+
358
+ async def get_service(self, profile: USPBusinessProfile, service_id: str) -> USPService:
359
+ client = await self._client(profile)
360
+ return await client.get_service(service_id)
361
+
362
+ # -- Availability --------------------------------------------------------
363
+
364
+ async def query_availability(
365
+ self,
366
+ profile: USPBusinessProfile,
367
+ service_id: str,
368
+ start_date: str,
369
+ end_date: str,
370
+ timezone: Optional[str] = None,
371
+ ) -> AvailabilityResult:
372
+ client = await self._client(profile)
373
+ return await client.query_availability(
374
+ service_id=service_id,
375
+ start_date=start_date,
376
+ end_date=end_date,
377
+ timezone=timezone,
378
+ )
379
+
380
+ async def hold_slot(
381
+ self,
382
+ profile: USPBusinessProfile,
383
+ slot_id: str,
384
+ service_id: str,
385
+ spots: int = 1,
386
+ ) -> Hold:
387
+ client = await self._client(profile)
388
+ hold = await client.hold_slot(slot_id, service_id, spots)
389
+ self.last_issued_credential = client.last_issued_credential
390
+ return hold
391
+
392
+ async def release_hold(
393
+ self,
394
+ profile: USPBusinessProfile,
395
+ hold_id: str,
396
+ *,
397
+ credential: Optional[BookingScopedCredential] = None,
398
+ ) -> dict:
399
+ client = await self._client(profile)
400
+ return await client.release_hold(hold_id, credential=credential)
401
+
402
+ # -- Booking -------------------------------------------------------------
403
+
404
+ async def create_booking(
405
+ self,
406
+ profile: USPBusinessProfile,
407
+ service_id: str,
408
+ slot_id: str,
409
+ buyer: Dict[str, Any],
410
+ party_size: int = 1,
411
+ resource_id: Optional[str] = None,
412
+ notes: Optional[str] = None,
413
+ delivery_address: Optional[Dict[str, Any]] = None,
414
+ idempotency_key: Optional[str] = None,
415
+ ) -> Booking:
416
+ client = await self._client(profile)
417
+ booking = await client.create_booking(
418
+ service_id=service_id,
419
+ slot_id=slot_id,
420
+ buyer=buyer,
421
+ party_size=party_size,
422
+ resource_id=resource_id,
423
+ notes=notes,
424
+ delivery_address=delivery_address,
425
+ idempotency_key=idempotency_key,
426
+ )
427
+ # The only point at which a credential is issued. The caller that owns
428
+ # session state persists it against (endpoint, booking id).
429
+ self.last_issued_credential = client.last_issued_credential
430
+ return booking
431
+
432
+ async def get_booking(
433
+ self,
434
+ profile: USPBusinessProfile,
435
+ booking_id: str,
436
+ *,
437
+ credential: Optional[BookingScopedCredential] = None,
438
+ credential_endpoint: Optional[str] = None,
439
+ ) -> Booking:
440
+ client = await self._client(
441
+ profile,
442
+ pop_key_endpoint=credential_endpoint if credential is not None else None,
443
+ )
444
+ return await client.get_booking(booking_id, credential=credential)
445
+
446
+ async def cancel_booking(
447
+ self,
448
+ profile: USPBusinessProfile,
449
+ booking_id: str,
450
+ reason: Optional[str] = None,
451
+ *,
452
+ credential: Optional[BookingScopedCredential] = None,
453
+ credential_endpoint: Optional[str] = None,
454
+ if_match: Optional[str] = None,
455
+ ) -> Booking:
456
+ client = await self._client(
457
+ profile,
458
+ pop_key_endpoint=credential_endpoint if credential is not None else None,
459
+ )
460
+ return await client.cancel_booking(
461
+ booking_id, reason=reason, credential=credential, if_match=if_match,
462
+ )
463
+
464
+ async def reschedule_booking(
465
+ self,
466
+ profile: USPBusinessProfile,
467
+ booking_id: str,
468
+ new_slot_id: str,
469
+ *,
470
+ credential: Optional[BookingScopedCredential] = None,
471
+ credential_endpoint: Optional[str] = None,
472
+ if_match: Optional[str] = None,
473
+ ) -> Booking:
474
+ client = await self._client(
475
+ profile,
476
+ pop_key_endpoint=credential_endpoint if credential is not None else None,
477
+ )
478
+ return await client.reschedule_booking(
479
+ booking_id, new_slot_id, credential=credential, if_match=if_match,
480
+ )
481
+
482
+ async def confirm_payment(
483
+ self,
484
+ profile: USPBusinessProfile,
485
+ booking_id: str,
486
+ payment_result: USPPaymentResult,
487
+ *,
488
+ credential: Optional[BookingScopedCredential] = None,
489
+ if_match: Optional[str] = None,
490
+ ) -> Booking:
491
+ client = await self._client(profile)
492
+ return await client.confirm_payment(
493
+ booking_id, payment_result, credential=credential, if_match=if_match,
494
+ )
@@ -0,0 +1,55 @@
1
+ """Customer calendar integration abstraction.
2
+
3
+ Strategy interface for checking the customer's personal calendar availability.
4
+ The product wires the live implementation:
5
+
6
+ live → booking_agent.calendar.google.GoogleCalendarService (real Google OAuth)
7
+
8
+ Tests inject their own implementation. Selection is independent of the USP
9
+ backend.
10
+ """
11
+ from abc import ABC, abstractmethod
12
+ from typing import Dict, List, Optional
13
+
14
+
15
+ class CustomerCalendarService(ABC):
16
+ """Strategy interface for customer calendar integration."""
17
+
18
+ @abstractmethod
19
+ async def initiate_connection(
20
+ self, session_id: str, user_key: str = "", mode: str = "freebusy", timeout: int = 600,
21
+ ) -> dict:
22
+ """Start the calendar connection flow without waiting for the user.
23
+
24
+ Returns:
25
+ ``{"connected": True}`` if no user step is needed, or
26
+ ``{"auth_url": "..."}`` if the user must authorize (live OAuth).
27
+ """
28
+ ...
29
+
30
+ def load_cached_connection(self, session_id: str, user_key: str, mode: str) -> Optional[dict]:
31
+ """Load an already-saved connection for ``mode`` without starting a sign-in."""
32
+ return None
33
+
34
+ async def poll_auth(self, session_id: str) -> dict:
35
+ """State of the sign-in started by ``initiate_connection``.
36
+
37
+ ``status`` is ``pending``, ``connected``, ``timeout``, ``failed``, or ``none``.
38
+ """
39
+ return {"status": "connected"}
40
+
41
+ async def cleanup_auth(self) -> bool:
42
+ """Stop a pending sign-in. True when a running listener was stopped."""
43
+ return False
44
+
45
+ @abstractmethod
46
+ async def is_connected(self, session_id: str) -> bool: ...
47
+
48
+ @abstractmethod
49
+ async def get_busy_times(
50
+ self, session_id: str, time_min: str, time_max: str
51
+ ) -> List[Dict[str, str]]: ...
52
+
53
+ async def get_calendar_timezone(self, session_id: str) -> Optional[str]:
54
+ """IANA zone of the user's primary calendar, when the connection can read it."""
55
+ return None
@@ -0,0 +1,41 @@
1
+ """Filter USP time slots against customer calendar busy intervals."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Dict, List, Sequence
6
+
7
+ from ..models.wire import TimeSlot
8
+
9
+
10
+ def _parse_iso(s: str) -> datetime:
11
+ """Parse an ISO8601 datetime string, handling the Z suffix for Python 3.10."""
12
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
13
+
14
+
15
+ def intervals_overlap(start_a: str, end_a: str, start_b: str, end_b: str) -> bool:
16
+ """Return True when two half-open-style intervals share any interior time.
17
+
18
+ Touching boundaries (end == start) do not count as overlap.
19
+ """
20
+ a_start = _parse_iso(start_a)
21
+ a_end = _parse_iso(end_a)
22
+ b_start = _parse_iso(start_b)
23
+ b_end = _parse_iso(end_b)
24
+ return a_start < b_end and b_start < a_end
25
+
26
+
27
+ def filter_slots_by_busy_times(
28
+ slots: Sequence[TimeSlot],
29
+ busy_blocks: List[Dict[str, str]],
30
+ ) -> List[TimeSlot]:
31
+ """Drop slots that overlap any busy block."""
32
+ if not busy_blocks:
33
+ return list(slots)
34
+ return [
35
+ slot
36
+ for slot in slots
37
+ if not any(
38
+ intervals_overlap(slot.start, slot.end, busy["start"], busy["end"])
39
+ for busy in busy_blocks
40
+ )
41
+ ]