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,582 @@
1
+ """Live Google Calendar integration via OAuth 2.0 + FreeBusy API.
2
+
3
+ Implements the Authorization Code flow with PKCE to obtain user consent,
4
+ then queries the Google Calendar FreeBusy endpoint for busy intervals.
5
+
6
+ The local OAuth callback is served by a short-lived child process (see
7
+ ``oauth_listener``) so the command that prints the sign-in link exits at once.
8
+ """
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import secrets
15
+ import time
16
+ import urllib.parse
17
+ from typing import Dict, List, Optional, Any
18
+
19
+ import httpx
20
+
21
+ from booking_agent.http_deadlines import coerce_timeout
22
+ from booking_agent.http_trace import traced_async_client
23
+
24
+ from ..config import load_config
25
+ from ..settings import google_calendar_token_dir
26
+ from . import CustomerCalendarService
27
+ from .scope import (
28
+ CALENDAR_MODE_FREEBUSY,
29
+ inspect_access_token,
30
+ oauth_scope_param,
31
+ scope_satisfies_mode,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+
38
+ def _client_id() -> str:
39
+ return load_config().google_calendar_client_id
40
+
41
+
42
+ def _client_secret() -> str:
43
+ return load_config().google_calendar_client_secret
44
+
45
+
46
+ def _redirect_base_url() -> str:
47
+ return f"http://localhost:{_redirect_port()}"
48
+
49
+
50
+ def _redirect_uri() -> str:
51
+ return f"{_redirect_base_url()}/oauth/google/callback"
52
+
53
+
54
+ def _launcher_uri() -> str:
55
+ return f"{_redirect_base_url()}/oauth/google/start"
56
+
57
+
58
+ def _redirect_port() -> int:
59
+ return load_config().google_calendar_redirect_port
60
+
61
+
62
+ _GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
63
+ _GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
64
+ _GOOGLE_FREEBUSY_ENDPOINT = "https://www.googleapis.com/calendar/v3/freeBusy"
65
+ _GOOGLE_PRIMARY_EVENTS_ENDPOINT = "https://www.googleapis.com/calendar/v3/calendars/primary/events"
66
+ _GOOGLE_SCOPE_DEFAULT = "https://www.googleapis.com/auth/calendar.freebusy"
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Token persistence (per-user, directory-backed + in-memory)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ _TOKEN_STORE: Dict[str, Dict[str, Any]] = {}
73
+ _PKCE_STORE: Dict[str, Dict[str, str]] = {}
74
+ # Sign-in attempts that ended with an error page, by OAuth state.
75
+ _OAUTH_FAILURES: Dict[str, str] = {}
76
+
77
+
78
+ def _user_cache_path(user_key: str) -> str:
79
+ """Return the per-user token cache file path (creates dir if needed)."""
80
+ cache_dir = google_calendar_token_dir()
81
+ os.makedirs(cache_dir, exist_ok=True)
82
+ safe_name = hashlib.sha256(user_key.encode()).hexdigest()[:16]
83
+ return os.path.join(cache_dir, f"{safe_name}.json")
84
+
85
+
86
+ def _load_cached_token(user_key: str) -> Optional[Dict[str, Any]]:
87
+ """Load a user's cached token from the file cache."""
88
+ path = _user_cache_path(user_key)
89
+ if not os.path.exists(path):
90
+ return None
91
+ try:
92
+ with open(path, "r") as f:
93
+ cache = json.load(f)
94
+ if time.time() < cache.get("expires_at", 0):
95
+ return cache
96
+ if cache.get("refresh_token"):
97
+ refreshed = _refresh_access_token_sync(cache["refresh_token"], user_key)
98
+ if refreshed:
99
+ return refreshed
100
+ except (json.JSONDecodeError, KeyError, OSError):
101
+ logger.debug("Failed to load calendar token cache for user", exc_info=True)
102
+ return None
103
+
104
+
105
+ def _save_token_cache(user_key: str, token_data: Dict[str, Any]) -> None:
106
+ """Persist token data to the per-user file cache."""
107
+ try:
108
+ path = _user_cache_path(user_key)
109
+ with open(path, "w") as f:
110
+ json.dump(token_data, f, indent=2)
111
+ os.chmod(path, 0o600)
112
+ except OSError:
113
+ logger.warning("Failed to save calendar token cache", exc_info=True)
114
+
115
+
116
+ def _refresh_access_token_sync(
117
+ refresh_token: str, user_key: str
118
+ ) -> Optional[Dict[str, Any]]:
119
+ """Synchronous token refresh using httpx (used during cache load)."""
120
+ import httpx as _httpx
121
+ try:
122
+ resp = _httpx.post(
123
+ _GOOGLE_TOKEN_ENDPOINT,
124
+ data={
125
+ "grant_type": "refresh_token",
126
+ "refresh_token": refresh_token,
127
+ "client_id": _client_id(),
128
+ "client_secret": _client_secret(),
129
+ },
130
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
131
+ timeout=coerce_timeout(15.0),
132
+ )
133
+ resp.raise_for_status()
134
+ data = resp.json()
135
+ token_entry = {
136
+ "access_token": data["access_token"],
137
+ "refresh_token": data.get("refresh_token", refresh_token),
138
+ "expires_at": time.time() + data.get("expires_in", 3600),
139
+ "user_key": user_key,
140
+ }
141
+ _save_token_cache(user_key, token_entry)
142
+ return token_entry
143
+ except Exception:
144
+ logger.warning("Google Calendar token refresh failed", exc_info=True)
145
+ return None
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # PKCE helpers
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def _generate_pkce_pair() -> tuple:
153
+ """Generate PKCE code_verifier and code_challenge (S256)."""
154
+ code_verifier = secrets.token_urlsafe(32)
155
+ code_challenge = (
156
+ base64.urlsafe_b64encode(
157
+ hashlib.sha256(code_verifier.encode("ascii")).digest()
158
+ )
159
+ .decode("ascii")
160
+ .rstrip("=")
161
+ )
162
+ return code_verifier, code_challenge
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # OAuth callback server (Starlette, daemon thread)
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def _resolve_oauth_state(request) -> Optional[str]:
170
+ """Resolve calendar session id from path, query, or sole pending PKCE entry."""
171
+ state = request.path_params.get("state") or request.query_params.get("state")
172
+ if state:
173
+ return state
174
+ if len(_PKCE_STORE) == 1:
175
+ sole = next(iter(_PKCE_STORE))
176
+ logger.info(
177
+ "OAuth start without state parameter; using sole pending session %s",
178
+ sole,
179
+ )
180
+ return sole
181
+ return None
182
+
183
+
184
+ def _build_launcher_url(session_id: str) -> str:
185
+ """Build the local launcher URL (state in path so chat links keep it)."""
186
+ safe_id = urllib.parse.quote(session_id, safe="")
187
+ return f"{_launcher_uri()}/{safe_id}"
188
+
189
+
190
+ def _build_google_auth_url(state: str, code_challenge: str, scope: str) -> str:
191
+ params = {
192
+ "response_type": "code",
193
+ "client_id": _client_id(),
194
+ "redirect_uri": _redirect_uri(),
195
+ "scope": scope,
196
+ "state": state,
197
+ "code_challenge": code_challenge,
198
+ "code_challenge_method": "S256",
199
+ "access_type": "offline",
200
+ }
201
+ return f"{_GOOGLE_AUTH_ENDPOINT}?{urllib.parse.urlencode(params)}"
202
+
203
+
204
+ def _build_callback_app():
205
+ from starlette.applications import Starlette
206
+ from starlette.requests import Request
207
+ from starlette.responses import HTMLResponse
208
+ from starlette.routing import Route
209
+
210
+ async def oauth_start(request: Request) -> HTMLResponse:
211
+ state = _resolve_oauth_state(request)
212
+ if not state:
213
+ return HTMLResponse(
214
+ _error_html("Missing state parameter"), status_code=400
215
+ )
216
+
217
+ pkce_data = _PKCE_STORE.get(state)
218
+ if not pkce_data:
219
+ return HTMLResponse(
220
+ _error_html("Unknown or expired session"), status_code=400
221
+ )
222
+
223
+ oauth_scope = pkce_data.get("oauth_scope") or _GOOGLE_SCOPE_DEFAULT
224
+ auth_url = _build_google_auth_url(state, pkce_data["code_challenge"], oauth_scope)
225
+ return HTMLResponse(_launcher_html(auth_url))
226
+
227
+ async def oauth_callback(request: Request) -> HTMLResponse:
228
+ code = request.query_params.get("code")
229
+ state = request.query_params.get("state") # calendar_session_id
230
+ error = request.query_params.get("error")
231
+
232
+ if error:
233
+ logger.warning("Google OAuth error: %s", error)
234
+ if state in _PKCE_STORE:
235
+ _OAUTH_FAILURES[state] = f"Google sign-in returned {error}."
236
+ return HTMLResponse(_error_html(error), status_code=400)
237
+
238
+ if not code or not state:
239
+ return HTMLResponse(
240
+ _error_html("Missing code or state parameter"), status_code=400
241
+ )
242
+
243
+ pkce_data = _PKCE_STORE.pop(state, None)
244
+ if not pkce_data:
245
+ return HTMLResponse(
246
+ _error_html("Unknown or expired session"), status_code=400
247
+ )
248
+
249
+ code_verifier = pkce_data["code_verifier"]
250
+ user_key = pkce_data.get("user_key", state)
251
+
252
+ token_data = await _exchange_code_for_token(code, code_verifier)
253
+ if not token_data:
254
+ _OAUTH_FAILURES[state] = "Google did not exchange the sign-in code for a token."
255
+ return HTMLResponse(
256
+ _error_html("Token exchange failed"), status_code=500
257
+ )
258
+
259
+ token_entry = {
260
+ "access_token": token_data["access_token"],
261
+ "refresh_token": token_data.get("refresh_token"),
262
+ "expires_at": time.time() + token_data.get("expires_in", 3600),
263
+ "user_key": user_key,
264
+ }
265
+ _TOKEN_STORE[state] = token_entry
266
+ _save_token_cache(user_key, token_entry)
267
+ logger.info("Google Calendar token stored for session %s", state)
268
+ return HTMLResponse(_success_html())
269
+
270
+ app = Starlette(
271
+ routes=[
272
+ Route("/oauth/google/start/{state}", oauth_start),
273
+ Route("/oauth/google/start", oauth_start),
274
+ Route("/oauth/google/callback", oauth_callback),
275
+ ]
276
+ )
277
+ return app
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Token exchange
282
+ # ---------------------------------------------------------------------------
283
+
284
+ async def _exchange_code_for_token(
285
+ code: str, code_verifier: str
286
+ ) -> Optional[dict]:
287
+ try:
288
+ async with traced_async_client(timeout=15.0) as client:
289
+ resp = await client.post(
290
+ _GOOGLE_TOKEN_ENDPOINT,
291
+ data={
292
+ "grant_type": "authorization_code",
293
+ "code": code,
294
+ "redirect_uri": _redirect_uri(),
295
+ "client_id": _client_id(),
296
+ "client_secret": _client_secret(),
297
+ "code_verifier": code_verifier,
298
+ },
299
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
300
+ )
301
+ resp.raise_for_status()
302
+ return resp.json()
303
+ except Exception:
304
+ logger.warning("Google token exchange failed", exc_info=True)
305
+ return None
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # HTML responses
310
+ # ---------------------------------------------------------------------------
311
+
312
+ def _close_script() -> str:
313
+ """JS that notifies sibling tabs and closes a script-opened popup."""
314
+ return """
315
+ try{var c=new BroadcastChannel('calendar-auth');c.postMessage({type:'oauth-complete'});c.close()}catch(e){}
316
+ setTimeout(function(){
317
+ window.close();
318
+ setTimeout(function(){
319
+ var el=document.getElementById('close-hint');
320
+ if(el) el.textContent='You can close this tab.';
321
+ },300);
322
+ },800);
323
+ """
324
+
325
+
326
+ def _launcher_html(auth_url: str) -> str:
327
+ auth_url_js = json.dumps(auth_url)
328
+ return f"""<!DOCTYPE html>
329
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
330
+ <title>Connect Calendar</title>
331
+ <style>
332
+ *{{margin:0;padding:0;box-sizing:border-box}}
333
+ body{{font-family:'Google Sans',system-ui,-apple-system,sans-serif;
334
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
335
+ background:#f8f9fa}}
336
+ .card{{text-align:center;padding:56px 48px 48px;max-width:440px;width:100%}}
337
+ h1{{font-size:24px;font-weight:500;color:#202124;margin-bottom:10px}}
338
+ p{{font-size:16px;color:#5f6368;line-height:1.6}}
339
+ .btn{{display:inline-block;margin-top:24px;padding:12px 36px;
340
+ font-size:15px;font-weight:500;color:#fff;background:#1a73e8;
341
+ border:none;border-radius:24px;cursor:pointer}}
342
+ .btn:hover{{background:#1557b0}}
343
+ .hint{{margin-top:28px;color:#80868b;font-size:14px}}
344
+ </style></head>
345
+ <body><div class="card" id="card">
346
+ <h1>Connect your calendar</h1>
347
+ <p id="status">Opening Google sign-in&hellip;</p>
348
+ <button class="btn" id="open-btn" style="display:none" type="button">Open sign-in window</button>
349
+ <p class="hint" id="close-hint"></p>
350
+ </div>
351
+ <script>
352
+ var AUTH_URL={auth_url_js};
353
+ var popup=null;
354
+ function onComplete(){{
355
+ document.getElementById('status').textContent='Calendar connected.';
356
+ document.getElementById('open-btn').style.display='none';
357
+ var hint=document.getElementById('close-hint');
358
+ hint.textContent='This window will close automatically.';
359
+ setTimeout(function(){{window.close();setTimeout(function(){{
360
+ if(hint) hint.textContent='You can close this tab.';
361
+ }},300);}},800);
362
+ }}
363
+ function openPopup(){{
364
+ popup=window.open(AUTH_URL,'google-oauth','width=520,height=680');
365
+ if(!popup||popup.closed){{
366
+ document.getElementById('status').textContent='Your browser blocked the sign-in popup.';
367
+ document.getElementById('open-btn').style.display='inline-block';
368
+ return;
369
+ }}
370
+ document.getElementById('open-btn').style.display='none';
371
+ }}
372
+ document.getElementById('open-btn').addEventListener('click',openPopup);
373
+ openPopup();
374
+ try{{
375
+ var ch=new BroadcastChannel('calendar-auth');
376
+ ch.onmessage=function(e){{if(e.data.type==='oauth-complete'){{ch.close();onComplete();}}}};
377
+ }}catch(e){{}}
378
+ </script>
379
+ </body></html>"""
380
+
381
+
382
+ def _success_html() -> str:
383
+ return f"""<!DOCTYPE html>
384
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
385
+ <title>Calendar Connected</title>
386
+ <style>
387
+ *{{margin:0;padding:0;box-sizing:border-box}}
388
+ body{{font-family:'Google Sans',system-ui,-apple-system,sans-serif;
389
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
390
+ background:#f8f9fa}}
391
+ .card{{text-align:center;padding:56px 48px 48px;max-width:440px;width:100%}}
392
+ .icon{{width:88px;height:88px;margin:0 auto 28px;border-radius:50%;
393
+ background:#e8f5e9;display:flex;align-items:center;justify-content:center}}
394
+ .icon svg{{width:44px;height:44px;stroke:#1e8e3e;stroke-width:2.5;fill:none;
395
+ stroke-linecap:round;stroke-linejoin:round;
396
+ stroke-dasharray:30;stroke-dashoffset:30;animation:draw .5s .2s forwards}}
397
+ @keyframes draw{{to{{stroke-dashoffset:0}}}}
398
+ h1{{font-size:28px;font-weight:500;color:#202124;margin-bottom:10px}}
399
+ p{{font-size:16px;color:#5f6368;line-height:1.6}}
400
+ .fade{{opacity:0;animation:fadeUp .4s .1s forwards}}
401
+ @keyframes fadeUp{{to{{opacity:1;transform:translateY(0)}}}}
402
+ .fade{{transform:translateY(12px)}}
403
+ </style></head>
404
+ <body><div class="card fade">
405
+ <div class="icon"><svg viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg></div>
406
+ <h1>Calendar connected</h1>
407
+ <p>Your schedule will be used to filter<br>available booking times.</p>
408
+ <p class="hint" id="close-hint" style="margin-top:28px;color:#80868b;font-size:14px">This window will close automatically.</p>
409
+ </div>
410
+ <script>{_close_script()}
411
+ </script>
412
+ </body></html>"""
413
+
414
+
415
+ def _error_html(message: str) -> str:
416
+ return f"""<!DOCTYPE html>
417
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
418
+ <title>Connection Failed</title>
419
+ <style>
420
+ *{{margin:0;padding:0;box-sizing:border-box}}
421
+ body{{font-family:'Google Sans',system-ui,-apple-system,sans-serif;
422
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
423
+ background:#f8f9fa}}
424
+ .card{{text-align:center;padding:56px 48px 48px;max-width:440px;width:100%}}
425
+ .icon{{width:88px;height:88px;margin:0 auto 28px;border-radius:50%;
426
+ background:#fce8e6;display:flex;align-items:center;justify-content:center}}
427
+ .icon svg{{width:44px;height:44px;stroke:#d93025;stroke-width:2.5;fill:none;
428
+ stroke-linecap:round;stroke-linejoin:round}}
429
+ h1{{font-size:28px;font-weight:500;color:#202124;margin-bottom:10px}}
430
+ p{{font-size:16px;color:#5f6368;line-height:1.6}}
431
+ .err{{color:#d93025;margin-top:14px;font-size:14px;
432
+ background:#fce8e6;padding:12px 18px;border-radius:10px;display:inline-block}}
433
+ .close-btn{{display:inline-block;margin-top:32px;padding:12px 36px;
434
+ font-size:15px;font-weight:500;color:#fff;background:#1a73e8;
435
+ border:none;border-radius:24px;cursor:pointer;letter-spacing:.25px;
436
+ transition:background .15s}}
437
+ .close-btn:hover{{background:#1557b0}}
438
+ </style></head>
439
+ <body><div class="card">
440
+ <div class="icon"><svg viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></div>
441
+ <h1>Connection failed</h1>
442
+ <p>Could not connect to your calendar.<br>Please return to the chat and try again.</p>
443
+ <div class="err">{message}</div>
444
+ <p style="margin-top:28px;color:#80868b;font-size:14px">You can close this tab.</p>
445
+ </div></body></html>"""
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # GoogleCalendarService
450
+ # ---------------------------------------------------------------------------
451
+
452
+ class GoogleCalendarService(CustomerCalendarService):
453
+ """Real Google Calendar OAuth 2.0 + FreeBusy API integration."""
454
+
455
+ def load_cached_connection(self, session_id: str, user_key: str, mode: str) -> Optional[dict]:
456
+ """Load a saved token that covers ``mode``. Needs no OAuth client (a host token works)."""
457
+ cached = _load_cached_token(user_key)
458
+ if not cached or not cached.get("access_token"):
459
+ return None
460
+ scope, _info = inspect_access_token(cached["access_token"])
461
+ if not scope or not scope_satisfies_mode(scope, mode):
462
+ return None
463
+ cached["user_key"] = user_key
464
+ _TOKEN_STORE[session_id] = cached
465
+ return {"connected": True, "scope": scope, "mode": mode}
466
+
467
+ async def initiate_connection(
468
+ self,
469
+ session_id: str,
470
+ user_key: str = "",
471
+ mode: str = CALENDAR_MODE_FREEBUSY,
472
+ timeout: int = 600,
473
+ ) -> dict:
474
+ from . import oauth_listener
475
+
476
+ user_key = user_key or session_id
477
+ cached = self.load_cached_connection(session_id, user_key, mode)
478
+ if cached:
479
+ return cached
480
+
481
+ if not _client_id():
482
+ raise RuntimeError(
483
+ "GOOGLE_CALENDAR_CLIENT_ID is not set. "
484
+ "Set it in ~/.booking-agent/.env (see `booking-agent config init`) to use "
485
+ "live Google Calendar integration."
486
+ )
487
+
488
+ requested_scope = oauth_scope_param(mode)
489
+ code_verifier, code_challenge = _generate_pkce_pair()
490
+ state = secrets.token_urlsafe(16)
491
+ oauth_listener.start(
492
+ port=_redirect_port(),
493
+ state=state,
494
+ pkce={
495
+ "code_verifier": code_verifier,
496
+ "code_challenge": code_challenge,
497
+ "user_key": user_key,
498
+ "oauth_scope": requested_scope,
499
+ "mode": mode,
500
+ },
501
+ mode=mode,
502
+ timeout=timeout,
503
+ )
504
+
505
+ # Launcher opens Google OAuth in a popup so the callback page can call
506
+ # window.close() (browsers block close on tabs the user opened directly).
507
+ # State lives in the path (not ?state=) so agent/chat links are less
508
+ # likely to drop it when presenting a shortened loopback URL.
509
+ auth_url = _build_launcher_url(state)
510
+ return {"auth_url": auth_url, "mode": mode, "requested_scope": requested_scope}
511
+
512
+ async def poll_auth(self, session_id: str) -> dict:
513
+ from . import oauth_listener
514
+
515
+ return oauth_listener.status()
516
+
517
+ async def cleanup_auth(self) -> bool:
518
+ from . import oauth_listener
519
+
520
+ return oauth_listener.cleanup()
521
+
522
+ async def is_connected(self, session_id: str) -> bool:
523
+ entry = _TOKEN_STORE.get(session_id)
524
+ if not entry:
525
+ return False
526
+ return time.time() < entry.get("expires_at", 0)
527
+
528
+ async def get_calendar_timezone(self, session_id: str) -> Optional[str]:
529
+ """Read ``timeZone`` from a one-row events.list (allowed by the calendar.events scope)."""
530
+ access_token = self._fresh_access_token(session_id)
531
+ async with traced_async_client(timeout=15.0) as client:
532
+ resp = await client.get(
533
+ _GOOGLE_PRIMARY_EVENTS_ENDPOINT,
534
+ params={"maxResults": "1", "fields": "timeZone"},
535
+ headers={"Authorization": f"Bearer {access_token}"},
536
+ )
537
+ resp.raise_for_status()
538
+ zone = resp.json().get("timeZone")
539
+ return zone if isinstance(zone, str) and zone else None
540
+
541
+ def _fresh_access_token(self, session_id: str) -> str:
542
+ entry = _TOKEN_STORE.get(session_id)
543
+ if not entry or not entry.get("access_token"):
544
+ raise RuntimeError(
545
+ "No Google Calendar token for this session. "
546
+ "The user may not have completed the OAuth flow."
547
+ )
548
+
549
+ if time.time() >= entry.get("expires_at", 0) and entry.get("refresh_token"):
550
+ uk = entry.get("user_key", session_id)
551
+ refreshed = _refresh_access_token_sync(entry["refresh_token"], uk)
552
+ if refreshed:
553
+ _TOKEN_STORE[session_id] = refreshed
554
+ entry = refreshed
555
+ else:
556
+ raise RuntimeError("Google Calendar token expired and refresh failed.")
557
+ return entry["access_token"]
558
+
559
+ async def get_busy_times(
560
+ self, session_id: str, time_min: str, time_max: str
561
+ ) -> List[Dict[str, str]]:
562
+ access_token = self._fresh_access_token(session_id)
563
+ body = {
564
+ "timeMin": time_min,
565
+ "timeMax": time_max,
566
+ "items": [{"id": "primary"}],
567
+ }
568
+
569
+ async with traced_async_client(timeout=15.0) as client:
570
+ resp = await client.post(
571
+ _GOOGLE_FREEBUSY_ENDPOINT,
572
+ json=body,
573
+ headers={"Authorization": f"Bearer {access_token}"},
574
+ )
575
+ resp.raise_for_status()
576
+ data = resp.json()
577
+
578
+ calendars = data.get("calendars", {})
579
+ primary = calendars.get("primary", {})
580
+ busy_list = primary.get("busy", [])
581
+
582
+ return [{"start": b["start"], "end": b["end"]} for b in busy_list]