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.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|