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,837 @@
|
|
|
1
|
+
"""``.usp-session.json`` schema, persistence, and read helpers.
|
|
2
|
+
|
|
3
|
+
The *store* is the single source of truth for the flow engine. Only
|
|
4
|
+
``session.py`` writes the file. The engine reads via these accessors and
|
|
5
|
+
calls ``save_session`` / ``save_store`` to persist transitions.
|
|
6
|
+
|
|
7
|
+
File format (schema_version 2):
|
|
8
|
+
{
|
|
9
|
+
"schema_version": 2,
|
|
10
|
+
"max_sessions": 30,
|
|
11
|
+
"sessions_created": <int, never decrements>,
|
|
12
|
+
"active_session_id": "<uuid>",
|
|
13
|
+
"sessions": [ { "id": "...", "created_at": "...", "user_request": "...", ... } ]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Migration: a file whose top-level JSON has no "sessions" key is the old
|
|
17
|
+
schema-version-1 single-object format and is transparently upgraded.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field, fields, asdict
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from booking_agent import settings
|
|
30
|
+
from booking_agent.secure_file import ensure_private_dir, write_private_text
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from booking_agent.authorization import BookingScopedCredential
|
|
34
|
+
|
|
35
|
+
SCHEMA_VERSION = 2 # per-Session object format version
|
|
36
|
+
STORE_SCHEMA_VERSION = 2 # SessionStore envelope version
|
|
37
|
+
SESSION_FILE_NAME = ".usp-session.json"
|
|
38
|
+
DEFAULT_MAX_SESSIONS = 30
|
|
39
|
+
|
|
40
|
+
VALID_PHASES = (
|
|
41
|
+
"INIT",
|
|
42
|
+
"PREREQ_OK",
|
|
43
|
+
"BUSINESS_SELECTED",
|
|
44
|
+
"CALENDAR_GATE",
|
|
45
|
+
"AVAILABILITY_QUERIED",
|
|
46
|
+
"SLOTS_PRESENTED",
|
|
47
|
+
"SLOT_SELECTED",
|
|
48
|
+
"BUYER_RESOLVED",
|
|
49
|
+
"BOOKED",
|
|
50
|
+
"PAYMENT_PENDING",
|
|
51
|
+
"CONFIRMED",
|
|
52
|
+
"FAILED",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
TERMINAL_PHASES = frozenset({"CONFIRMED", "FAILED"})
|
|
56
|
+
|
|
57
|
+
# Phases where a selected future slot may still need cancel authority or resume.
|
|
58
|
+
_ACTIONABLE_IN_PROGRESS_PHASES = frozenset({
|
|
59
|
+
"SLOT_SELECTED",
|
|
60
|
+
"BUYER_RESOLVED",
|
|
61
|
+
"BOOKED",
|
|
62
|
+
"PAYMENT_PENDING",
|
|
63
|
+
"CONFIRMED",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Booking statuses where the appointment can no longer be canceled or moved.
|
|
67
|
+
# ``confirmed`` is absent on purpose: polling is done, but the buyer can still
|
|
68
|
+
# cancel or reschedule a confirmed booking.
|
|
69
|
+
INACTIVE_BOOKING_STATUSES = frozenset({"completed", "canceled", "no_show"})
|
|
70
|
+
|
|
71
|
+
BUYER_SOURCE_LINKCLI = "link-cli user-info retrieve"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Business:
|
|
76
|
+
profile_url: Optional[str] = None
|
|
77
|
+
rest_endpoint: Optional[str] = None
|
|
78
|
+
name: Optional[str] = None
|
|
79
|
+
timezone: Optional[str] = None # IANA zone of the selected business; None until a source names one
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class AvailabilityWindow:
|
|
84
|
+
from_: Optional[str] = None # ISO 8601
|
|
85
|
+
to: Optional[str] = None # ISO 8601
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SlotRef:
|
|
90
|
+
slot_id: str
|
|
91
|
+
start: str
|
|
92
|
+
end: str
|
|
93
|
+
duration: Optional[str] = None
|
|
94
|
+
price_amount: Optional[int] = None
|
|
95
|
+
# Other resources' slot ids for the same start and end, tried in order if this one is taken.
|
|
96
|
+
alternate_slot_ids: List[str] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Buyer:
|
|
101
|
+
name: str = ""
|
|
102
|
+
email: str = ""
|
|
103
|
+
phone: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class BookingRef:
|
|
108
|
+
id: Optional[str] = None
|
|
109
|
+
checkout_path: Optional[str] = None # ucp | none
|
|
110
|
+
payment_context: Optional[Dict[str, Any]] = None
|
|
111
|
+
action_expires_at: Optional[str] = None # ISO 8601; when the checkout expires
|
|
112
|
+
|
|
113
|
+
# -- USP Section 10.1.6 per-resource authorization ------------------------
|
|
114
|
+
# The booking-scoped credential issued at creation, keyed implicitly by
|
|
115
|
+
# (endpoint, id): both are recorded so the credential is never presented to a
|
|
116
|
+
# business other than the one that issued it.
|
|
117
|
+
endpoint: Optional[str] = None # issuing business REST endpoint
|
|
118
|
+
credential: Optional[str] = None # bsc_… value
|
|
119
|
+
credential_expires_at: Optional[str] = None # ISO 8601
|
|
120
|
+
credential_jkt: Optional[str] = None # cnf.jkt the credential is bound to
|
|
121
|
+
credential_scope: Optional[List[str]] = None # USP methods it authorizes, if narrowed
|
|
122
|
+
revision: Optional[str] = None # for If-Match conditional writes (Section 5.6)
|
|
123
|
+
booking_url: Optional[str] = None # human handoff target on 401/expiry
|
|
124
|
+
status: Optional[str] = None # last booking status read from or set by the business
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class PaymentRef:
|
|
129
|
+
status: Optional[str] = None # pending | paid | failed
|
|
130
|
+
spend_request_id: Optional[str] = None
|
|
131
|
+
order_id: Optional[str] = None # UCP complete_checkout order correlation (step 20)
|
|
132
|
+
spend_approval_surfaced: bool = False # True after pay request emitted the approval URL once
|
|
133
|
+
spend_approval_user_surfaced: bool = False # True after agent acked posting URL to the user
|
|
134
|
+
approved_at: Optional[str] = None # ISO 8601 when Link approval was observed (convenience copy)
|
|
135
|
+
spend_action_surfaced: Optional[str] = None # SpendNextAction.key already shown to the user
|
|
136
|
+
payment_method_id: Optional[str] = None # funding source of the last spend request, for a replacement
|
|
137
|
+
# Last spend request a complete_checkout declined; spent, never offered again.
|
|
138
|
+
declined_spend_request_id: Optional[str] = None
|
|
139
|
+
declined_code: Optional[str] = None # UCP message code of that decline
|
|
140
|
+
declined_checkout_id: Optional[str] = None
|
|
141
|
+
# complete_checkout captured the money but the booking is not confirmed; no
|
|
142
|
+
# further charge may run until the booking is resolved.
|
|
143
|
+
captured_unconfirmed: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class LastError:
|
|
148
|
+
code: str
|
|
149
|
+
message: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ActivityEntry:
|
|
154
|
+
at: str
|
|
155
|
+
kind: str # command | error
|
|
156
|
+
text: str
|
|
157
|
+
code: Optional[str] = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class Session:
|
|
162
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
163
|
+
correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
164
|
+
created_at: str = field(
|
|
165
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
166
|
+
)
|
|
167
|
+
user_request: str = ""
|
|
168
|
+
schema_version: int = SCHEMA_VERSION
|
|
169
|
+
flow_phase: str = "INIT"
|
|
170
|
+
business: Business = field(default_factory=Business)
|
|
171
|
+
service_timezone: Optional[str] = None # selected registry result's zone; business.timezone wins
|
|
172
|
+
buyer_timezone: Optional[str] = None # IANA zone of the buyer's default Link shipping address
|
|
173
|
+
buyer_timezone_source: Optional[str] = None
|
|
174
|
+
buyer_timezone_error: Optional[str] = None # sanitized code from the last failed lookup
|
|
175
|
+
timezone_difference_shown: bool = False # user picked a slot from a dual-zone list
|
|
176
|
+
slots_presented_dual_zone: bool = False # last slots list carried buyer-zone displays
|
|
177
|
+
calendar_gate_completed: bool = False
|
|
178
|
+
calendar_state: str = "unset" # connected | skipped | unset
|
|
179
|
+
calendar_mode: str = "unset" # none | freebusy | events | unset
|
|
180
|
+
calendar_route: Optional[str] = None # connector | local-oauth
|
|
181
|
+
calendar_token_env: Optional[str] = None # name of the host variable, never its value
|
|
182
|
+
link_auth_gate_completed: bool = False
|
|
183
|
+
availability_window: AvailabilityWindow = field(default_factory=AvailabilityWindow)
|
|
184
|
+
slots_presented: List[SlotRef] = field(default_factory=list)
|
|
185
|
+
slot_id: Optional[str] = None
|
|
186
|
+
slot_user_confirmed: bool = False
|
|
187
|
+
service_id: Optional[str] = None
|
|
188
|
+
selection_mismatch: Optional[str] = None # less-direct label the user confirmed at business use
|
|
189
|
+
timezone_conflict_confirmed: bool = False # user accepted a service-location/timezone conflict
|
|
190
|
+
live_service: Optional[Dict[str, Any]] = None # GET /services/{id} snapshot before availability
|
|
191
|
+
payment_timing: Optional[str] = None # at_booking | at_service | deposit_required | None
|
|
192
|
+
payment_route: Optional[str] = None # free | at_service | at_booking | deposit_required
|
|
193
|
+
selected_term_id: Optional[str] = None
|
|
194
|
+
payment_schedules: Optional[List[Dict[str, Any]]] = None
|
|
195
|
+
due_now_amount_minor: Optional[int] = None
|
|
196
|
+
payment_disclosures: Optional[List[Dict[str, Any]]] = None
|
|
197
|
+
disclosures_user_confirmed: bool = False
|
|
198
|
+
total_confirmed_amount_minor: Optional[int] = None # checkout total the user re-approved
|
|
199
|
+
accepted_term: Optional[Dict[str, Any]] = None
|
|
200
|
+
complete_checkout_idempotency_key: Optional[str] = None
|
|
201
|
+
# Direct POST /bookings the business refused as invalid_profile_url:
|
|
202
|
+
# {idempotency_key, fingerprint, retry_command}. Reused only for the same booking.
|
|
203
|
+
pending_booking_attempt: Optional[Dict[str, Any]] = None
|
|
204
|
+
checkout_mode: Optional[str] = None # ucp_native | direct; set by business use
|
|
205
|
+
supported_checkout_modes: List[str] = field(default_factory=list)
|
|
206
|
+
checkout_prereq_completed: bool = False
|
|
207
|
+
ucp_checkout_rest_base: Optional[str] = None
|
|
208
|
+
ucp_checkout_session_id: Optional[str] = None
|
|
209
|
+
ucp_checkout_amount_minor: Optional[int] = None # totals.total from create_checkout (minor units)
|
|
210
|
+
ucp_merchant_quote: Optional[Dict[str, Any]] = None # merchant checkout totals (Track A)
|
|
211
|
+
ucp_checkout_links: Optional[List[Dict[str, Any]]] = None
|
|
212
|
+
ucp_checkout_policies: Optional[List[Dict[str, Any]]] = None
|
|
213
|
+
ucp_checkout_messages: Optional[List[Dict[str, Any]]] = None
|
|
214
|
+
ucp_spt_network_id: Optional[str] = None # from checkout payment_handlers config at book
|
|
215
|
+
# Negotiated UCP capability set. Authoritative source is the checkout response
|
|
216
|
+
# echo (checkout.ucp.capabilities/.version), NOT the platform-side profile
|
|
217
|
+
# intersection - see ucp_capabilities.py. Persisted so complete_checkout can
|
|
218
|
+
# reuse it without re-fetching the profile.
|
|
219
|
+
ucp_protocol_version: Optional[str] = None # authoritative negotiated UCP version (response echo)
|
|
220
|
+
ucp_negotiated_capabilities: Dict[str, List[str]] = field(default_factory=dict) # {namespace: [versions]}
|
|
221
|
+
ucp_capabilities_authoritative: bool = False # True once confirmed by the business response echo
|
|
222
|
+
ucp_checkout_cancel_attempted: bool = False
|
|
223
|
+
ucp_checkout_cancel_succeeded: Optional[bool] = None
|
|
224
|
+
ucp_checkout_cancel_error: Optional[str] = None # short, non-secret cancel failure summary
|
|
225
|
+
buyer_source: str = "none" # link-cli user-info retrieve | none
|
|
226
|
+
buyer: Buyer = field(default_factory=Buyer)
|
|
227
|
+
booking: BookingRef = field(default_factory=BookingRef)
|
|
228
|
+
payment: PaymentRef = field(default_factory=PaymentRef)
|
|
229
|
+
last_error: Optional[LastError] = None
|
|
230
|
+
activity_log: List[ActivityEntry] = field(default_factory=list)
|
|
231
|
+
discover_query: Optional[str] = None # last registry search query (support reports read it)
|
|
232
|
+
discover_location_lat: Optional[float] = None
|
|
233
|
+
discover_location_lng: Optional[float] = None
|
|
234
|
+
discover_location_radius_km: Optional[float] = None
|
|
235
|
+
discover_registry_url: Optional[str] = None # resolved POST URL for registry search
|
|
236
|
+
discover_registry_request_body: Optional[Dict[str, Any]] = None # raw search_services JSON
|
|
237
|
+
discover_services: List[Dict[str, Any]] = field(default_factory=list)
|
|
238
|
+
flow_timings: Dict[str, Dict[str, float]] = field(default_factory=dict)
|
|
239
|
+
human_gate_timings: Dict[str, float] = field(default_factory=dict)
|
|
240
|
+
skill_compliance_validated: bool = False
|
|
241
|
+
# Reschedule working state for a CONFIRMED booking. slot_id and slots_presented
|
|
242
|
+
# keep describing the booked slot until the business accepts the move.
|
|
243
|
+
reschedule_original_slot_id: Optional[str] = None # set by reschedule start
|
|
244
|
+
reschedule_slots: List[SlotRef] = field(default_factory=list)
|
|
245
|
+
reschedule_slot_id: Optional[str] = None
|
|
246
|
+
reschedule_user_confirmed: bool = False
|
|
247
|
+
# Consented support report (spec #307). The bytes are frozen at offer time and
|
|
248
|
+
# are the only thing `flow support-report send` may deliver.
|
|
249
|
+
support_report_declined: bool = False
|
|
250
|
+
support_report_asked: bool = False
|
|
251
|
+
support_report_bytes: Optional[str] = None
|
|
252
|
+
support_report_recovery_command: Optional[str] = None
|
|
253
|
+
support_report_send_failures: int = 0
|
|
254
|
+
# Area the user confirmed for a place-based search:
|
|
255
|
+
# {label, lat, lng, source, link_address_id, street_level, confirmed}.
|
|
256
|
+
search_area: Optional[Dict[str, Any]] = None
|
|
257
|
+
# Geocoded proposal shown to the user and not yet confirmed; ``purpose`` is
|
|
258
|
+
# ``search`` or ``service_address``.
|
|
259
|
+
pending_area: Optional[Dict[str, Any]] = None
|
|
260
|
+
# Confirmed street address (USP DeliveryAddress fields) for home visits.
|
|
261
|
+
service_address: Optional[Dict[str, Any]] = None
|
|
262
|
+
service_address_label: Optional[str] = None
|
|
263
|
+
area_resume_command: Optional[str] = None # command to rerun after an address confirmation
|
|
264
|
+
booking_notes: Optional[str] = None # note for the business, set at buyer resolve
|
|
265
|
+
calendar_timezone: Optional[str] = None # primary Google Calendar zone (events mode only)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass
|
|
269
|
+
class SessionStore:
|
|
270
|
+
schema_version: int = STORE_SCHEMA_VERSION
|
|
271
|
+
max_sessions: int = DEFAULT_MAX_SESSIONS
|
|
272
|
+
sessions_created: int = 0 # lifetime counter; never decrements on reset
|
|
273
|
+
active_session_id: Optional[str] = None
|
|
274
|
+
last_calendar_state: Optional[str] = None # connected | skipped
|
|
275
|
+
last_calendar_mode: Optional[str] = None # freebusy | events | none
|
|
276
|
+
last_calendar_route: Optional[str] = None # connector | local-oauth
|
|
277
|
+
last_calendar_token_env: Optional[str] = None # name of the host variable, never its value
|
|
278
|
+
sessions: List[Session] = field(default_factory=list)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Path resolution
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def session_path() -> Path:
|
|
286
|
+
"""Return the path to the session store file.
|
|
287
|
+
|
|
288
|
+
Override with ``BOOKING_AGENT_SESSION_DIR`` env var (used in tests to isolate
|
|
289
|
+
each test's store from the real ``~/.booking-agent`` directory).
|
|
290
|
+
"""
|
|
291
|
+
base = settings.booking_agent_home()
|
|
292
|
+
ensure_private_dir(base)
|
|
293
|
+
return base / SESSION_FILE_NAME
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Store-level I/O
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def load_store() -> SessionStore:
|
|
301
|
+
p = session_path()
|
|
302
|
+
if not p.exists():
|
|
303
|
+
max_s = settings.get_int("BOOKING_AGENT_MAX_SESSIONS")
|
|
304
|
+
return SessionStore(max_sessions=max_s)
|
|
305
|
+
raw = json.loads(p.read_text())
|
|
306
|
+
if "sessions" not in raw:
|
|
307
|
+
store = _migrate_v1(raw)
|
|
308
|
+
else:
|
|
309
|
+
store = _store_from_dict(raw)
|
|
310
|
+
if maintain_store(store):
|
|
311
|
+
save_store(store)
|
|
312
|
+
return store
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def save_store(store: SessionStore) -> Path:
|
|
316
|
+
# The store now carries booking-scoped credentials, so it is written 0600.
|
|
317
|
+
p = session_path()
|
|
318
|
+
write_private_text(p, json.dumps(_store_to_dict(store), indent=2, sort_keys=False))
|
|
319
|
+
return p
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Session-level helpers (thin wrappers that the flow commands use)
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def bind_session_observability(session: Optional[Session]) -> None:
|
|
327
|
+
"""Bind the flow correlation id for outbound HTTP and stderr events."""
|
|
328
|
+
from booking_agent.observability import bind, reset
|
|
329
|
+
|
|
330
|
+
if session and session.correlation_id:
|
|
331
|
+
bind(session.correlation_id)
|
|
332
|
+
else:
|
|
333
|
+
reset()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def load_session() -> Optional[Session]:
|
|
337
|
+
session = get_active_session(load_store())
|
|
338
|
+
bind_session_observability(session)
|
|
339
|
+
return session
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
SUPPORT_REPORT_FIELDS = (
|
|
343
|
+
"support_report_declined",
|
|
344
|
+
"support_report_asked",
|
|
345
|
+
"support_report_bytes",
|
|
346
|
+
"support_report_recovery_command",
|
|
347
|
+
"support_report_send_failures",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def save_session(session: Session, *, write_support_report: bool = False) -> Path:
|
|
352
|
+
"""Persist an updated session back into the store.
|
|
353
|
+
|
|
354
|
+
Commands often save an in-memory session loaded before ``emit_error`` froze a
|
|
355
|
+
support report, so only callers that own the report may change those fields.
|
|
356
|
+
"""
|
|
357
|
+
store = load_store()
|
|
358
|
+
for i, s in enumerate(store.sessions):
|
|
359
|
+
if s.id == session.id:
|
|
360
|
+
if not write_support_report:
|
|
361
|
+
for name in SUPPORT_REPORT_FIELDS:
|
|
362
|
+
setattr(session, name, getattr(s, name))
|
|
363
|
+
store.sessions[i] = session
|
|
364
|
+
return save_store(store)
|
|
365
|
+
# Defensive: session not found in store — append it
|
|
366
|
+
store.sessions.append(session)
|
|
367
|
+
store.active_session_id = session.id
|
|
368
|
+
return save_store(store)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def delete_session() -> bool:
|
|
372
|
+
"""Remove the active session entry from the store. Returns True if removed."""
|
|
373
|
+
store = load_store()
|
|
374
|
+
active = get_active_session(store)
|
|
375
|
+
if active is None:
|
|
376
|
+
return False
|
|
377
|
+
store.sessions = [s for s in store.sessions if s.id != active.id]
|
|
378
|
+
store.active_session_id = store.sessions[-1].id if store.sessions else None
|
|
379
|
+
save_store(store)
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def must_load() -> Session:
|
|
384
|
+
s = load_session()
|
|
385
|
+
if s is None:
|
|
386
|
+
raise FileNotFoundError(
|
|
387
|
+
"No active session. Run `booking-agent flow init` first."
|
|
388
|
+
)
|
|
389
|
+
return s
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Store mutation helpers
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def get_active_session(store: SessionStore) -> Optional[Session]:
|
|
397
|
+
if not store.sessions:
|
|
398
|
+
return None
|
|
399
|
+
if store.active_session_id:
|
|
400
|
+
for s in store.sessions:
|
|
401
|
+
if s.id == store.active_session_id:
|
|
402
|
+
return s
|
|
403
|
+
return store.sessions[-1]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def set_active_session(store: SessionStore, session_id: str) -> bool:
|
|
407
|
+
"""Point the store's active pointer at ``session_id``. Returns False if not found."""
|
|
408
|
+
for s in store.sessions:
|
|
409
|
+
if s.id == session_id:
|
|
410
|
+
store.active_session_id = session_id
|
|
411
|
+
return True
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def add_session(store: SessionStore, session: Session) -> None:
|
|
416
|
+
"""Append a new session, set it as active, drop oldest non-actionable if over limit."""
|
|
417
|
+
store.sessions.append(session)
|
|
418
|
+
store.sessions_created += 1
|
|
419
|
+
store.active_session_id = session.id
|
|
420
|
+
maintain_store(store)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def session_slot_end(session: Session, *, now: Optional[datetime] = None) -> Optional[datetime]:
|
|
424
|
+
"""Return the UTC end time of the selected slot, if known."""
|
|
425
|
+
_ = now
|
|
426
|
+
if not session.slot_id:
|
|
427
|
+
return None
|
|
428
|
+
for slot in session.slots_presented:
|
|
429
|
+
if slot.slot_id != session.slot_id:
|
|
430
|
+
continue
|
|
431
|
+
try:
|
|
432
|
+
end = datetime.fromisoformat(slot.end.replace("Z", "+00:00"))
|
|
433
|
+
except (ValueError, AttributeError):
|
|
434
|
+
return None
|
|
435
|
+
if end.tzinfo is None:
|
|
436
|
+
end = end.replace(tzinfo=timezone.utc)
|
|
437
|
+
return end
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def is_actionable(session: Session, *, now: Optional[datetime] = None) -> bool:
|
|
442
|
+
"""True while the consumer may still need cancel/resume for this booking."""
|
|
443
|
+
if session.flow_phase == "FAILED":
|
|
444
|
+
return False
|
|
445
|
+
if session.booking.status in INACTIVE_BOOKING_STATUSES:
|
|
446
|
+
return False
|
|
447
|
+
now = now or datetime.now(timezone.utc)
|
|
448
|
+
slot_end = session_slot_end(session)
|
|
449
|
+
|
|
450
|
+
if session.booking.id:
|
|
451
|
+
if slot_end is None:
|
|
452
|
+
return True
|
|
453
|
+
return slot_end >= now
|
|
454
|
+
|
|
455
|
+
if session.flow_phase in _ACTIONABLE_IN_PROGRESS_PHASES and session.slot_id:
|
|
456
|
+
if slot_end is None:
|
|
457
|
+
return True
|
|
458
|
+
return slot_end >= now
|
|
459
|
+
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def archive_expired_session(session: Session, *, now: Optional[datetime] = None) -> bool:
|
|
464
|
+
"""Strip secrets from a session whose appointment has passed. Returns True if modified."""
|
|
465
|
+
now = now or datetime.now(timezone.utc)
|
|
466
|
+
slot_end = session_slot_end(session)
|
|
467
|
+
if slot_end is None or slot_end >= now:
|
|
468
|
+
return False
|
|
469
|
+
clear_credential(session)
|
|
470
|
+
session.buyer = Buyer()
|
|
471
|
+
session.buyer_source = "none"
|
|
472
|
+
session.search_area = None
|
|
473
|
+
session.pending_area = None
|
|
474
|
+
session.service_address = None
|
|
475
|
+
session.service_address_label = None
|
|
476
|
+
session.booking_notes = None
|
|
477
|
+
# Keep dispute join keys (#189): spend request id, order id, payment status, approval time.
|
|
478
|
+
# Wipe only ephemeral approval-gate flags and anything secret-bearing.
|
|
479
|
+
session.payment.spend_approval_surfaced = False
|
|
480
|
+
session.payment.spend_approval_user_surfaced = False
|
|
481
|
+
session.payment.spend_action_surfaced = None
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def maintain_store(store: SessionStore, *, now: Optional[datetime] = None) -> bool:
|
|
486
|
+
"""Archive past appointments and trim only non-actionable sessions."""
|
|
487
|
+
now = now or datetime.now(timezone.utc)
|
|
488
|
+
modified = False
|
|
489
|
+
for session in store.sessions:
|
|
490
|
+
if archive_expired_session(session, now=now):
|
|
491
|
+
modified = True
|
|
492
|
+
before_len = len(store.sessions)
|
|
493
|
+
before_active = store.active_session_id
|
|
494
|
+
_trim_non_actionable_sessions(store, now=now)
|
|
495
|
+
if len(store.sessions) != before_len or store.active_session_id != before_active:
|
|
496
|
+
modified = True
|
|
497
|
+
return modified
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _trim_non_actionable_sessions(store: SessionStore, *, now: Optional[datetime] = None) -> None:
|
|
501
|
+
now = now or datetime.now(timezone.utc)
|
|
502
|
+
while len(store.sessions) > store.max_sessions:
|
|
503
|
+
droppable = [
|
|
504
|
+
(idx, sess)
|
|
505
|
+
for idx, sess in enumerate(store.sessions)
|
|
506
|
+
if not is_actionable(sess, now=now)
|
|
507
|
+
]
|
|
508
|
+
if not droppable:
|
|
509
|
+
break
|
|
510
|
+
drop_idx = min(droppable, key=lambda item: item[1].created_at)[0]
|
|
511
|
+
dropped = store.sessions.pop(drop_idx)
|
|
512
|
+
if store.active_session_id == dropped.id:
|
|
513
|
+
store.active_session_id = (
|
|
514
|
+
store.sessions[-1].id if store.sessions else None
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def forget_session(store: SessionStore, session_id: str) -> bool:
|
|
519
|
+
"""Remove one session by id. Does not touch Link/Google identity stores."""
|
|
520
|
+
for idx, session in enumerate(store.sessions):
|
|
521
|
+
if session.id != session_id:
|
|
522
|
+
continue
|
|
523
|
+
store.sessions.pop(idx)
|
|
524
|
+
if store.active_session_id == session_id:
|
|
525
|
+
store.active_session_id = (
|
|
526
|
+
store.sessions[-1].id if store.sessions else None
|
|
527
|
+
)
|
|
528
|
+
return True
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# Booking-scoped credentials (USP Section 10.1.6)
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
def record_issued_credential(
|
|
537
|
+
session: Session,
|
|
538
|
+
credential: Optional["BookingScopedCredential"],
|
|
539
|
+
*,
|
|
540
|
+
endpoint: Optional[str],
|
|
541
|
+
booking_url: Optional[str] = None,
|
|
542
|
+
revision: Optional[str] = None,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Persist the credential a business issued at resource creation.
|
|
545
|
+
|
|
546
|
+
The endpoint is stored alongside it so ``credential_for`` can refuse to hand
|
|
547
|
+
it to a different business.
|
|
548
|
+
"""
|
|
549
|
+
session.booking.endpoint = endpoint
|
|
550
|
+
if booking_url:
|
|
551
|
+
session.booking.booking_url = booking_url
|
|
552
|
+
if revision:
|
|
553
|
+
session.booking.revision = revision
|
|
554
|
+
if credential is None:
|
|
555
|
+
return
|
|
556
|
+
session.booking.credential = credential.value
|
|
557
|
+
session.booking.credential_expires_at = credential.expires_at.isoformat()
|
|
558
|
+
session.booking.credential_jkt = credential.cnf.jkt if credential.cnf else None
|
|
559
|
+
session.booking.credential_scope = list(credential.scope) if credential.scope else None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def credential_for(
|
|
563
|
+
session: Session, *, endpoint: Optional[str],
|
|
564
|
+
) -> Optional["BookingScopedCredential"]:
|
|
565
|
+
"""Return the stored credential, but only for the business that issued it.
|
|
566
|
+
|
|
567
|
+
A booking-scoped credential is a secret scoped to one resource at one
|
|
568
|
+
business. Presenting it elsewhere would leak it to a party with no claim to
|
|
569
|
+
it, so an endpoint mismatch returns None rather than the credential.
|
|
570
|
+
"""
|
|
571
|
+
from booking_agent.authorization import BookingScopedCredential
|
|
572
|
+
|
|
573
|
+
ref = session.booking
|
|
574
|
+
if not ref.credential or not ref.credential_expires_at:
|
|
575
|
+
return None
|
|
576
|
+
if endpoint and ref.endpoint and endpoint != ref.endpoint:
|
|
577
|
+
return None
|
|
578
|
+
payload: Dict[str, Any] = {
|
|
579
|
+
"value": ref.credential,
|
|
580
|
+
"expires_at": ref.credential_expires_at,
|
|
581
|
+
}
|
|
582
|
+
if ref.credential_jkt:
|
|
583
|
+
payload["cnf"] = {"jkt": ref.credential_jkt}
|
|
584
|
+
if ref.credential_scope:
|
|
585
|
+
payload["scope"] = ref.credential_scope
|
|
586
|
+
try:
|
|
587
|
+
return BookingScopedCredential.model_validate(payload)
|
|
588
|
+
except Exception:
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def credential_for_session_resource(
|
|
593
|
+
session: Session,
|
|
594
|
+
*,
|
|
595
|
+
endpoint: Optional[str],
|
|
596
|
+
resolved_business_endpoint: Optional[str] = None,
|
|
597
|
+
) -> Optional["BookingScopedCredential"]:
|
|
598
|
+
"""Return a credential for a related binding of this session's business.
|
|
599
|
+
|
|
600
|
+
Exact issuer matches remain the default. UCP-native checkout is the sole
|
|
601
|
+
cross-binding exception: checkout issues the booking credential, while the
|
|
602
|
+
resulting booking resource is read from the USP REST binding in the same
|
|
603
|
+
business profile.
|
|
604
|
+
"""
|
|
605
|
+
exact = credential_for(session, endpoint=endpoint)
|
|
606
|
+
if exact is not None:
|
|
607
|
+
return exact
|
|
608
|
+
|
|
609
|
+
issuer = session.booking.endpoint
|
|
610
|
+
checkout_endpoint = session.ucp_checkout_rest_base
|
|
611
|
+
usp_endpoint = session.business.rest_endpoint or resolved_business_endpoint
|
|
612
|
+
if (
|
|
613
|
+
issuer
|
|
614
|
+
and endpoint
|
|
615
|
+
and checkout_endpoint
|
|
616
|
+
and usp_endpoint
|
|
617
|
+
and issuer == checkout_endpoint
|
|
618
|
+
and endpoint == usp_endpoint
|
|
619
|
+
):
|
|
620
|
+
return credential_for(session, endpoint=issuer)
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def clear_credential(session: Session) -> None:
|
|
625
|
+
"""Drop the credential at a terminal state; it is invalid from that point."""
|
|
626
|
+
ref = session.booking
|
|
627
|
+
ref.credential = None
|
|
628
|
+
ref.credential_expires_at = None
|
|
629
|
+
ref.credential_jkt = None
|
|
630
|
+
ref.credential_scope = None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def endpoint_still_in_use(
|
|
634
|
+
store: SessionStore, endpoint: Optional[str], *, excluding_session_id: Optional[str] = None,
|
|
635
|
+
) -> bool:
|
|
636
|
+
"""True when any other session still holds a live credential at *endpoint*.
|
|
637
|
+
|
|
638
|
+
This is what makes PoP key destruction safe. The key is per-endpoint while
|
|
639
|
+
credentials are per-resource, so dropping the key while another booking's
|
|
640
|
+
credential is still bound to it would leave that booking permanently
|
|
641
|
+
uncancellable -- USP defines no re-issuance path for a key you no longer
|
|
642
|
+
hold.
|
|
643
|
+
"""
|
|
644
|
+
if not endpoint:
|
|
645
|
+
return False
|
|
646
|
+
now = datetime.now(timezone.utc)
|
|
647
|
+
for sess in store.sessions:
|
|
648
|
+
if excluding_session_id and sess.id == excluding_session_id:
|
|
649
|
+
continue
|
|
650
|
+
ref = sess.booking
|
|
651
|
+
if ref.endpoint != endpoint or not ref.credential:
|
|
652
|
+
continue
|
|
653
|
+
if not ref.credential_expires_at:
|
|
654
|
+
return True
|
|
655
|
+
try:
|
|
656
|
+
expires = datetime.fromisoformat(ref.credential_expires_at)
|
|
657
|
+
except ValueError:
|
|
658
|
+
return True
|
|
659
|
+
if expires.tzinfo is None:
|
|
660
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
661
|
+
if expires > now:
|
|
662
|
+
return True
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# Serialisation
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def _store_to_dict(store: SessionStore) -> Dict[str, Any]:
|
|
671
|
+
d: Dict[str, Any] = {
|
|
672
|
+
"schema_version": store.schema_version,
|
|
673
|
+
"max_sessions": store.max_sessions,
|
|
674
|
+
"sessions_created": store.sessions_created,
|
|
675
|
+
"active_session_id": store.active_session_id,
|
|
676
|
+
"sessions": [_session_to_dict(s) for s in store.sessions],
|
|
677
|
+
}
|
|
678
|
+
if store.last_calendar_state is not None:
|
|
679
|
+
d["last_calendar_state"] = store.last_calendar_state
|
|
680
|
+
if store.last_calendar_mode is not None:
|
|
681
|
+
d["last_calendar_mode"] = store.last_calendar_mode
|
|
682
|
+
if store.last_calendar_route is not None:
|
|
683
|
+
d["last_calendar_route"] = store.last_calendar_route
|
|
684
|
+
if store.last_calendar_token_env is not None:
|
|
685
|
+
d["last_calendar_token_env"] = store.last_calendar_token_env
|
|
686
|
+
return d
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _store_from_dict(raw: Dict[str, Any]) -> SessionStore:
|
|
690
|
+
max_s = int(os.environ.get(
|
|
691
|
+
"BOOKING_AGENT_MAX_SESSIONS",
|
|
692
|
+
raw.get("max_sessions") or settings.get_int("BOOKING_AGENT_MAX_SESSIONS"),
|
|
693
|
+
))
|
|
694
|
+
return SessionStore(
|
|
695
|
+
schema_version=raw.get("schema_version", STORE_SCHEMA_VERSION),
|
|
696
|
+
max_sessions=max_s,
|
|
697
|
+
sessions_created=raw.get("sessions_created", 0),
|
|
698
|
+
active_session_id=raw.get("active_session_id"),
|
|
699
|
+
last_calendar_state=raw.get("last_calendar_state"),
|
|
700
|
+
last_calendar_mode=raw.get("last_calendar_mode"),
|
|
701
|
+
last_calendar_route=raw.get("last_calendar_route"),
|
|
702
|
+
last_calendar_token_env=raw.get("last_calendar_token_env"),
|
|
703
|
+
sessions=[_session_from_dict(s) for s in (raw.get("sessions") or [])],
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _session_to_dict(s: Session) -> Dict[str, Any]:
|
|
708
|
+
d = asdict(s)
|
|
709
|
+
aw = d.pop("availability_window")
|
|
710
|
+
d["availability_window"] = {"from": aw.get("from_"), "to": aw.get("to")}
|
|
711
|
+
buyer = d.get("buyer")
|
|
712
|
+
if isinstance(buyer, dict):
|
|
713
|
+
# Agent half of spec #159: do not persist buyer contact on disk.
|
|
714
|
+
# Name and buyer_source stay; email/phone are rehydrated from link-cli.
|
|
715
|
+
buyer["email"] = ""
|
|
716
|
+
buyer["phone"] = None
|
|
717
|
+
return d
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _known_fields(cls, raw: Dict[str, Any]):
|
|
721
|
+
names = {f.name for f in fields(cls)}
|
|
722
|
+
return cls(**{k: v for k, v in raw.items() if k in names})
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _session_from_dict(raw: Dict[str, Any]) -> Session:
|
|
726
|
+
raw = dict(raw) # avoid mutating caller's dict
|
|
727
|
+
aw_in = raw.get("availability_window") or {}
|
|
728
|
+
raw["availability_window"] = {
|
|
729
|
+
"from_": aw_in.get("from") or aw_in.get("from_"),
|
|
730
|
+
"to": aw_in.get("to"),
|
|
731
|
+
}
|
|
732
|
+
return Session(
|
|
733
|
+
id=raw.get("id") or str(uuid.uuid4()),
|
|
734
|
+
correlation_id=raw.get("correlation_id") or str(uuid.uuid4()),
|
|
735
|
+
created_at=raw.get("created_at") or datetime.now(timezone.utc).isoformat(),
|
|
736
|
+
user_request=raw.get("user_request", ""),
|
|
737
|
+
schema_version=raw.get("schema_version", SCHEMA_VERSION),
|
|
738
|
+
flow_phase=raw.get("flow_phase", "INIT"),
|
|
739
|
+
business=Business(**(raw.get("business") or {})),
|
|
740
|
+
service_timezone=raw.get("service_timezone"),
|
|
741
|
+
buyer_timezone=raw.get("buyer_timezone"),
|
|
742
|
+
buyer_timezone_source=raw.get("buyer_timezone_source"),
|
|
743
|
+
buyer_timezone_error=raw.get("buyer_timezone_error"),
|
|
744
|
+
timezone_difference_shown=bool(raw.get("timezone_difference_shown", False)),
|
|
745
|
+
slots_presented_dual_zone=bool(raw.get("slots_presented_dual_zone", False)),
|
|
746
|
+
calendar_gate_completed=raw.get("calendar_gate_completed", False),
|
|
747
|
+
calendar_state=raw.get("calendar_state", "unset"),
|
|
748
|
+
calendar_mode=raw.get("calendar_mode", "unset"),
|
|
749
|
+
calendar_route=raw.get("calendar_route"),
|
|
750
|
+
calendar_token_env=raw.get("calendar_token_env"),
|
|
751
|
+
link_auth_gate_completed=raw.get("link_auth_gate_completed", False),
|
|
752
|
+
availability_window=AvailabilityWindow(**raw["availability_window"]),
|
|
753
|
+
slots_presented=[SlotRef(**s) for s in (raw.get("slots_presented") or [])],
|
|
754
|
+
slot_id=raw.get("slot_id"),
|
|
755
|
+
slot_user_confirmed=raw.get("slot_user_confirmed", False),
|
|
756
|
+
service_id=raw.get("service_id"),
|
|
757
|
+
selection_mismatch=raw.get("selection_mismatch"),
|
|
758
|
+
timezone_conflict_confirmed=bool(raw.get("timezone_conflict_confirmed", False)),
|
|
759
|
+
live_service=raw.get("live_service"),
|
|
760
|
+
payment_timing=raw.get("payment_timing"),
|
|
761
|
+
payment_route=raw.get("payment_route"),
|
|
762
|
+
selected_term_id=raw.get("selected_term_id"),
|
|
763
|
+
payment_schedules=list(raw.get("payment_schedules") or []) or None,
|
|
764
|
+
due_now_amount_minor=raw.get("due_now_amount_minor"),
|
|
765
|
+
payment_disclosures=list(raw.get("payment_disclosures") or []) or None,
|
|
766
|
+
disclosures_user_confirmed=bool(raw.get("disclosures_user_confirmed", False)),
|
|
767
|
+
total_confirmed_amount_minor=raw.get("total_confirmed_amount_minor"),
|
|
768
|
+
accepted_term=raw.get("accepted_term"),
|
|
769
|
+
complete_checkout_idempotency_key=raw.get("complete_checkout_idempotency_key"),
|
|
770
|
+
pending_booking_attempt=(
|
|
771
|
+
raw["pending_booking_attempt"]
|
|
772
|
+
if isinstance(raw.get("pending_booking_attempt"), dict)
|
|
773
|
+
else None
|
|
774
|
+
),
|
|
775
|
+
checkout_mode=raw.get("checkout_mode"),
|
|
776
|
+
supported_checkout_modes=list(raw.get("supported_checkout_modes") or []),
|
|
777
|
+
checkout_prereq_completed=raw.get("checkout_prereq_completed", False),
|
|
778
|
+
ucp_checkout_rest_base=raw.get("ucp_checkout_rest_base"),
|
|
779
|
+
ucp_checkout_session_id=raw.get("ucp_checkout_session_id"),
|
|
780
|
+
ucp_checkout_amount_minor=raw.get("ucp_checkout_amount_minor"),
|
|
781
|
+
ucp_merchant_quote=raw.get("ucp_merchant_quote"),
|
|
782
|
+
ucp_checkout_links=list(raw.get("ucp_checkout_links") or []) or None,
|
|
783
|
+
ucp_checkout_policies=list(raw.get("ucp_checkout_policies") or []) or None,
|
|
784
|
+
ucp_checkout_messages=list(raw.get("ucp_checkout_messages") or []) or None,
|
|
785
|
+
ucp_spt_network_id=raw.get("ucp_spt_network_id"),
|
|
786
|
+
ucp_protocol_version=raw.get("ucp_protocol_version"),
|
|
787
|
+
ucp_negotiated_capabilities=dict(raw.get("ucp_negotiated_capabilities") or {}),
|
|
788
|
+
ucp_capabilities_authoritative=raw.get("ucp_capabilities_authoritative", False),
|
|
789
|
+
ucp_checkout_cancel_attempted=bool(raw.get("ucp_checkout_cancel_attempted", False)),
|
|
790
|
+
ucp_checkout_cancel_succeeded=raw.get("ucp_checkout_cancel_succeeded"),
|
|
791
|
+
ucp_checkout_cancel_error=raw.get("ucp_checkout_cancel_error"),
|
|
792
|
+
buyer_source=raw.get("buyer_source", "none"),
|
|
793
|
+
buyer=Buyer(**(raw.get("buyer") or {})),
|
|
794
|
+
booking=_known_fields(BookingRef, raw.get("booking") or {}),
|
|
795
|
+
payment=PaymentRef(**(raw.get("payment") or {})),
|
|
796
|
+
last_error=(LastError(**raw["last_error"]) if raw.get("last_error") else None),
|
|
797
|
+
activity_log=[ActivityEntry(**e) for e in (raw.get("activity_log") or [])],
|
|
798
|
+
discover_query=raw.get("discover_query"),
|
|
799
|
+
discover_location_lat=raw.get("discover_location_lat"),
|
|
800
|
+
discover_location_lng=raw.get("discover_location_lng"),
|
|
801
|
+
discover_location_radius_km=raw.get("discover_location_radius_km"),
|
|
802
|
+
discover_registry_url=raw.get("discover_registry_url"),
|
|
803
|
+
discover_registry_request_body=raw.get("discover_registry_request_body"),
|
|
804
|
+
discover_services=list(raw.get("discover_services") or []),
|
|
805
|
+
flow_timings=dict(raw.get("flow_timings") or {}),
|
|
806
|
+
human_gate_timings=dict(raw.get("human_gate_timings") or {}),
|
|
807
|
+
skill_compliance_validated=raw.get("skill_compliance_validated", False),
|
|
808
|
+
reschedule_original_slot_id=raw.get("reschedule_original_slot_id"),
|
|
809
|
+
reschedule_slots=[SlotRef(**s) for s in (raw.get("reschedule_slots") or [])],
|
|
810
|
+
reschedule_slot_id=raw.get("reschedule_slot_id"),
|
|
811
|
+
reschedule_user_confirmed=bool(raw.get("reschedule_user_confirmed", False)),
|
|
812
|
+
support_report_declined=bool(raw.get("support_report_declined", False)),
|
|
813
|
+
support_report_asked=bool(raw.get("support_report_asked", False)),
|
|
814
|
+
support_report_bytes=raw.get("support_report_bytes"),
|
|
815
|
+
support_report_recovery_command=raw.get("support_report_recovery_command"),
|
|
816
|
+
support_report_send_failures=int(raw.get("support_report_send_failures") or 0),
|
|
817
|
+
search_area=raw.get("search_area"),
|
|
818
|
+
pending_area=raw.get("pending_area"),
|
|
819
|
+
service_address=raw.get("service_address"),
|
|
820
|
+
service_address_label=raw.get("service_address_label"),
|
|
821
|
+
area_resume_command=raw.get("area_resume_command"),
|
|
822
|
+
booking_notes=raw.get("booking_notes"),
|
|
823
|
+
calendar_timezone=raw.get("calendar_timezone"),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _migrate_v1(raw: Dict[str, Any]) -> SessionStore:
|
|
828
|
+
"""Wrap a schema-version-1 single-object file in a v2 store envelope."""
|
|
829
|
+
session = _session_from_dict(raw)
|
|
830
|
+
max_s = settings.get_int("BOOKING_AGENT_MAX_SESSIONS")
|
|
831
|
+
return SessionStore(
|
|
832
|
+
schema_version=STORE_SCHEMA_VERSION,
|
|
833
|
+
max_sessions=max_s,
|
|
834
|
+
sessions_created=1, # the migrated session counts toward the lifetime total
|
|
835
|
+
active_session_id=session.id,
|
|
836
|
+
sessions=[session],
|
|
837
|
+
)
|