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,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…</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]
|