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,1210 @@
1
+ """Consented support report.
2
+
3
+ When a booking fails in a way the agent cannot recover from, ``emit_error`` offers
4
+ the buyer a one-time note to the team that looks after the booking assistant. The
5
+ CLI builds the note, freezes its bytes on the session, and is the only thing that
6
+ can send them, to one baked-in ingest URL, and only with ``--user-confirmed``.
7
+
8
+ Every value in the note is projected from an explicit allowlist. Free text that a
9
+ buyer, agent, or business controls (exception messages, error details, request
10
+ text, notes, addresses) is never copied. A note that does not pass the ingest's
11
+ schema (``support_bundle_schema``) is never stored or sent. Building the note is a
12
+ side channel: no failure here may change the error output it describes.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import platform
19
+ import re
20
+ import shlex
21
+ import sys
22
+ import sysconfig
23
+ import traceback
24
+ from dataclasses import fields as dataclass_fields
25
+ from datetime import datetime, timezone
26
+ from pathlib import PurePath
27
+ from typing import Any, Callable, Dict, List, Optional, Tuple
28
+ from urllib.parse import urlsplit, urlunsplit
29
+
30
+ import httpx
31
+ import typer
32
+
33
+ from .output import AgentOutput, emit, emit_error
34
+ from .session import (
35
+ AvailabilityWindow,
36
+ BookingRef,
37
+ Business,
38
+ LastError,
39
+ PaymentRef,
40
+ Session,
41
+ SlotRef,
42
+ load_session,
43
+ save_session,
44
+ )
45
+ from .support_bundle_schema import validate_bundle
46
+
47
+ # Apps Script web app owned by support@booking-agent.dev.
48
+ DEFAULT_SUPPORT_INGEST_URL = (
49
+ "https://script.google.com/macros/s/"
50
+ "AKfycbxfFz5rR9eqMpEsxBtWL4sB8hoLs1ZmHlsC7wan-xB3rOAG1TowUow1UF_ZDEHegzRFNA/exec"
51
+ )
52
+ # Apps Script answers a POST with a redirect to its echo host, where the
53
+ # script's JSON reply is served. Only this exact host pair is ever followed.
54
+ _APPS_SCRIPT_HOST = "script.google.com"
55
+ _APPS_SCRIPT_ECHO_HOST = "script.googleusercontent.com"
56
+ # The fixed refusal reasons ingest/apps-script/Code.gs replies with.
57
+ INGEST_REJECTION_REASONS = frozenset({
58
+ "no_body", "media_type", "too_large", "invalid_json", "schema", "rate_limited", "internal",
59
+ })
60
+ SKILL_VERSION = "0.46.0"
61
+ BUNDLE_VERSION = 1
62
+ MAX_BUNDLE_BYTES = 64 * 1024
63
+ SEND_TIMEOUT_S = 10.0
64
+
65
+ SUPPORT_REPORT_COMMAND = "booking-agent flow support-report"
66
+ SEND_COMMAND = "booking-agent flow support-report send --user-confirmed"
67
+ DECLINE_COMMAND = "booking-agent flow support-report decline"
68
+ AWAITING_SUPPORT_REPORT_CONSENT = "support_report_consent"
69
+
70
+ CONSENT_MESSAGE = (
71
+ "Something went wrong that I can't fix from here. May I send a note to the team "
72
+ "that looks after the booking assistant? It describes what went wrong and the "
73
+ "booking steps so far, and leaves out your name, phone, email, address, and "
74
+ "payment details."
75
+ )
76
+ RETRY_CONSENT_MESSAGE = (
77
+ "Last time I couldn't send the note to the team that looks after the booking "
78
+ "assistant. Shall I try again? It still leaves out your name, phone, email, "
79
+ "address, and payment details."
80
+ )
81
+ SENT_MESSAGE = "I've sent the note to the team. Thank you."
82
+ SEND_FAILED_MESSAGE = (
83
+ "I couldn't send the note to the team just now. Nothing about your booking has "
84
+ "changed."
85
+ )
86
+
87
+ # Codes the skill's recovery tables (Errors, the Step 6c-iii matrix, and cancel)
88
+ # already explain, plus codes its prose documents. Kept equal to the skill by
89
+ # test_support_report.py.
90
+ SKILL_TABLE_CODES = frozenset({
91
+ "agent_profile_unresolvable", "approval_not_surfaced_to_user", "approval_timeout", "booking_canceled_after_payment",
92
+ "buyer_email_invalid", "buyer_not_from_link_cli", "calendar_gate_not_completed",
93
+ "cancel_failed", "cancel_forbidden", "cancel_open_selection_conflict",
94
+ "cancel_open_selection_required", "cancel_unauthorized", "checkout_already_in_progress",
95
+ "checkout_prereq_not_completed", "checkout_total_unconfirmed", "complete_in_progress",
96
+ "context_required", "disclosures_unconfirmed", "discover_radius_required",
97
+ "link_address_not_found", "link_auth_expired",
98
+ "link_auth_gate_not_completed", "link_auth_not_started", "link_auth_timeout",
99
+ "mandatory_acceptance_pending", "missing_network_id_for_spt", "payment_already_captured",
100
+ "payment_authentication_unsupported", "payment_captured_booking_unconfirmed",
101
+ "payment_declined", "payment_failed", "payment_not_processed", "payment_timing_changed",
102
+ "payment_timing_not_supported", "payment_unrecoverable", "profile_unreachable",
103
+ "reschedule_at_business",
104
+ "reschedule_booking_not_active", "reschedule_failed", "reschedule_fee_unsupported",
105
+ "reschedule_not_actionable", "reschedule_not_allowed", "reschedule_price_changed",
106
+ "reschedule_price_unknown", "reschedule_same_slot", "reschedule_unauthorized",
107
+ "search_area_input_invalid", "search_area_not_user_confirmed", "search_area_unconfirmed",
108
+ "service_address_unconfirmed", "service_details_changed", "service_not_available_in_area",
109
+ "shared_payment_token_not_issued", "slot_not_selected", "spend_request_not_approved",
110
+ "spend_request_replacement_required", "timezone_confirmation_required",
111
+ "ucp_complete_failed", "ucp_native_required",
112
+ })
113
+ SKILL_PROSE_CODES = frozenset({
114
+ "calendar_callback_port_busy", "calendar_insufficient_scope", "calendar_oauth_failed",
115
+ "calendar_oauth_not_configured", "calendar_oauth_not_started", "calendar_oauth_timeout",
116
+ "calendar_route_required", "calendar_token_env_required",
117
+ "session_active", "session_not_found",
118
+ })
119
+ EXPECTED_CODES = SKILL_TABLE_CODES | SKILL_PROSE_CODES
120
+ # The business is still working; seeing these again is progress, not a failed step.
121
+ STILL_WORKING_CODES = frozenset({"complete_in_progress", "checkout_already_in_progress"})
122
+ POLL_COMMANDS = frozenset({"book poll", "pay poll", "link-auth poll"})
123
+ NEVER_OFFER_CODES = frozenset({
124
+ "no_support_report",
125
+ "support_report_not_confirmed",
126
+ "support_report_send_failed",
127
+ "support_report_unavailable",
128
+ })
129
+ # A terminal stop -> the commands its output forbids. Errors from those commands
130
+ # afterwards are the agent breaking a rule, not a product failure.
131
+ TERMINAL_STOP_FORBIDDEN: Dict[str, Tuple[str, ...]] = {
132
+ "payment_unrecoverable": ("flow pay ucp-complete", "flow pay request"),
133
+ }
134
+ MAX_SEND_ATTEMPTS = 2
135
+ MAX_COMMAND_CHARS = 4000
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Leaf projectors: each returns a safe JSON value or None, and never raises on
139
+ # an unexpected type.
140
+ # ---------------------------------------------------------------------------
141
+
142
+ _ID_RE = re.compile(r"^[A-Za-z0-9_.:\-]{1,160}$")
143
+ _TOKEN_RE = re.compile(r"^[a-z0-9_.]{1,64}$")
144
+ _TS_RE = re.compile(
145
+ r"^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?)?(?:Z|[+-]\d{2}:?\d{2})?$"
146
+ )
147
+ _TZ_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+\-]{0,30}(?:/[A-Za-z0-9_+\-]{1,30}){0,3}$")
148
+ _DURATION_RE = re.compile(r"^P[0-9YMWDTHS.]{1,30}$")
149
+ _FLAG_RE = re.compile(r"^--[a-z][a-z0-9-]{0,40}$")
150
+ _KEY_RE = re.compile(r"^[a-z0-9_]{1,64}$")
151
+ _QUALNAME_RE = re.compile(r"^[A-Za-z0-9_.<>]{1,200}$")
152
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
153
+ _PROJECT_PACKAGES = ("booking_agent_cli", "booking_agent")
154
+
155
+
156
+ def _id(v: Any) -> Optional[str]:
157
+ return v if isinstance(v, str) and _ID_RE.match(v) else None
158
+
159
+
160
+ def _token(v: Any) -> Optional[str]:
161
+ return v if isinstance(v, str) and _TOKEN_RE.match(v) else None
162
+
163
+
164
+ def _ts(v: Any) -> Optional[str]:
165
+ return v if isinstance(v, str) and _TS_RE.match(v) else None
166
+
167
+
168
+ def _tz(v: Any) -> Optional[str]:
169
+ return v if isinstance(v, str) and _TZ_RE.match(v) else None
170
+
171
+
172
+ def _bool(v: Any) -> Optional[bool]:
173
+ return v if isinstance(v, bool) else None
174
+
175
+
176
+ def _num(v: Any) -> Optional[float]:
177
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
178
+ return None
179
+ return v
180
+
181
+
182
+ def _label(v: Any) -> Optional[str]:
183
+ """Public display names (business, service): single line, capped."""
184
+ if not isinstance(v, str):
185
+ return None
186
+ text = " ".join(_CONTROL_RE.sub(" ", v).split())
187
+ return text[:120] or None
188
+
189
+
190
+ def _url(v: Any) -> Optional[str]:
191
+ if not isinstance(v, str) or len(v) > 2048:
192
+ return None
193
+ try:
194
+ parts = urlsplit(v)
195
+ if parts.scheme not in ("http", "https") or not parts.hostname:
196
+ return None
197
+ netloc = parts.hostname + (f":{parts.port}" if parts.port else "")
198
+ except ValueError:
199
+ return None
200
+ return urlunsplit((parts.scheme, netloc, parts.path, "", ""))[:300]
201
+
202
+
203
+ def _duration(v: Any) -> Any:
204
+ if isinstance(v, str):
205
+ return v if _DURATION_RE.match(v) or _ID_RE.match(v) else None
206
+ if isinstance(v, dict):
207
+ out = {k: _duration(v.get(k)) for k in ("fixed", "min", "max") if k in v}
208
+ return out or None
209
+ return _num(v)
210
+
211
+
212
+ def _list(v: Any, fn: Callable[[Any], Any], limit: int = 50) -> Optional[list]:
213
+ if not isinstance(v, list):
214
+ return None
215
+ out = [item for item in (fn(x) for x in v[:limit]) if item is not None]
216
+ return out
217
+
218
+
219
+ _AMOUNT_KEYS: Dict[str, Callable[[Any], Any]] = {
220
+ "amount": _num, "amount_minor": _num, "minor_units": _num, "value": _num,
221
+ "decimal_places": _num, "currency": _id, "model": _id, "payment_timing": _id,
222
+ }
223
+
224
+
225
+ def _amountish(v: Any) -> Any:
226
+ if isinstance(v, dict):
227
+ out = {k: fn(v.get(k)) for k, fn in _AMOUNT_KEYS.items() if k in v}
228
+ return out or None
229
+ return _num(v)
230
+
231
+
232
+ def _terms(v: Any, depth: int = 0) -> Any:
233
+ """Payment terms, schedules, disclosures: ids, kinds, amounts, currency, dates."""
234
+ if depth > 3:
235
+ return None
236
+ if isinstance(v, list):
237
+ return [t for t in (_terms(x, depth + 1) for x in v[:20]) if t is not None]
238
+ if not isinstance(v, dict):
239
+ return None
240
+ out: Dict[str, Any] = {}
241
+ for key, item in v.items():
242
+ if key in ("id", "term_id", "kind", "type", "code", "timing", "model", "currency"):
243
+ out[key] = _id(item)
244
+ elif key in ("amount", "price", "due_now"):
245
+ out[key] = _amountish(item)
246
+ elif key in ("amount_minor", "minor_units", "due_now_minor", "percent", "percentage"):
247
+ out[key] = _num(item)
248
+ elif key in ("due", "due_at", "due_date"):
249
+ out[key] = _ts(item)
250
+ elif key in ("schedule", "schedules", "installments", "payments", "items"):
251
+ out[key] = _terms(item, depth + 1)
252
+ return out or None
253
+
254
+
255
+ def _quote(v: Any) -> Any:
256
+ if not isinstance(v, dict):
257
+ return None
258
+ out: Dict[str, Any] = {}
259
+ for key, item in v.items():
260
+ if not isinstance(key, str) or not _KEY_RE.match(key):
261
+ continue
262
+ if key == "currency":
263
+ out[key] = _id(item)
264
+ elif _num(item) is not None or isinstance(item, bool):
265
+ out[key] = item
266
+ return out or None
267
+
268
+
269
+ def _messages(v: Any) -> Optional[list]:
270
+ def one(m: Any) -> Any:
271
+ if not isinstance(m, dict) or _token(m.get("code")) is None:
272
+ return None
273
+ return {"code": m.get("code"), "severity": _token(m.get("severity"))}
274
+
275
+ return _list(v, one, 20)
276
+
277
+
278
+ def _capabilities(v: Any) -> Any:
279
+ if not isinstance(v, dict):
280
+ return None
281
+ out = {}
282
+ for ns, versions in list(v.items())[:30]:
283
+ if _id(ns):
284
+ out[ns] = _list(versions, _id, 10) or []
285
+ return out
286
+
287
+
288
+ def _live_service(v: Any) -> Any:
289
+ if not isinstance(v, dict):
290
+ return None
291
+ pricing = v.get("pricing") if isinstance(v.get("pricing"), dict) else None
292
+ channels = v.get("channels")
293
+ channel = v.get("channel")
294
+ channel_types = _list(channels, lambda c: _id(c.get("type")) if isinstance(c, dict) else _id(c), 10)
295
+ if channel_types is None and isinstance(channel, dict):
296
+ channel_types = [t for t in [_id(channel.get("type"))] if t]
297
+ return {
298
+ "id": _id(v.get("id")),
299
+ "service_name": _label(v.get("name")),
300
+ "duration": _duration(v.get("duration")),
301
+ "price": _amountish(pricing if pricing is not None else v.get("price")),
302
+ "payment_timing": _id(v.get("payment_timing") or (pricing or {}).get("payment_timing")),
303
+ "channel_types": channel_types,
304
+ }
305
+
306
+
307
+ def _slot(v: Any) -> Any:
308
+ if isinstance(v, SlotRef):
309
+ get = lambda k: getattr(v, k, None) # noqa: E731
310
+ elif isinstance(v, dict):
311
+ get = v.get
312
+ else:
313
+ return None
314
+ out = {}
315
+ for name, rule in SLOT_RULES.items():
316
+ key, fn = rule
317
+ out[key] = fn(get(name))
318
+ return out
319
+
320
+
321
+ def _discover(v: Any) -> Optional[list]:
322
+ def one(row: Any) -> Any:
323
+ if not isinstance(row, dict):
324
+ return None
325
+ return {"service_id": _id(row.get("service_id")), "service_name": _label(row.get("service_name"))}
326
+
327
+ return _list(v, one, 20)
328
+
329
+
330
+ def _timings(v: Any) -> Any:
331
+ if not isinstance(v, dict):
332
+ return None
333
+ out = {}
334
+ for step, item in list(v.items())[:50]:
335
+ if not (isinstance(step, str) and _KEY_RE.match(step)):
336
+ continue
337
+ if isinstance(item, dict):
338
+ out[step] = {k: x for k, x in item.items() if isinstance(k, str) and _KEY_RE.match(k) and _num(x) is not None}
339
+ elif _num(item) is not None:
340
+ out[step] = item
341
+ return out
342
+
343
+
344
+ def _window(v: Any) -> Any:
345
+ if not isinstance(v, AvailabilityWindow):
346
+ return None
347
+ return {"from": _ts(v.from_), "to": _ts(v.to)}
348
+
349
+
350
+ def _last_error(v: Any) -> Any:
351
+ return {"code": _id(v.code)} if isinstance(v, LastError) else None
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Session projection. Every dataclass field maps to EXCLUDE or (out_key, fn);
356
+ # a field missing from its table fails the classification test.
357
+ # ---------------------------------------------------------------------------
358
+
359
+ EXCLUDE = None
360
+ FieldRule = Optional[Tuple[str, Callable[[Any], Any]]]
361
+
362
+ BUSINESS_RULES: Dict[str, FieldRule] = {
363
+ "profile_url": ("profile_url", _url),
364
+ "rest_endpoint": ("rest_endpoint", _url),
365
+ "name": ("business_name", _label),
366
+ "timezone": ("timezone", _tz),
367
+ }
368
+ BOOKING_RULES: Dict[str, FieldRule] = {
369
+ "id": ("id", _id),
370
+ "checkout_path": ("checkout_path", _id),
371
+ "payment_context": EXCLUDE,
372
+ "action_expires_at": ("action_expires_at", _ts),
373
+ "endpoint": ("endpoint", _url),
374
+ "credential": ("scoped_auth_present", lambda v: bool(v)),
375
+ "credential_expires_at": ("scoped_auth_expires_at", _ts),
376
+ "credential_jkt": EXCLUDE,
377
+ "credential_scope": ("scoped_auth_scope", lambda v: _list(v, _id, 20)),
378
+ "revision": ("revision", _id),
379
+ "booking_url": EXCLUDE,
380
+ "status": ("status", _id),
381
+ }
382
+ PAYMENT_RULES: Dict[str, FieldRule] = {
383
+ "status": ("status", _id),
384
+ "spend_request_id": ("spend_request_id", _id),
385
+ "order_id": ("order_id", _id),
386
+ "spend_approval_surfaced": ("spend_approval_surfaced", _bool),
387
+ "spend_approval_user_surfaced": ("spend_approval_user_surfaced", _bool),
388
+ "approved_at": ("approved_at", _ts),
389
+ "spend_action_surfaced": ("spend_action_surfaced", _id),
390
+ "payment_method_id": EXCLUDE,
391
+ "declined_spend_request_id": ("declined_spend_request_id", _id),
392
+ "declined_code": ("declined_code", _token),
393
+ "declined_checkout_id": ("declined_checkout_id", _id),
394
+ "captured_unconfirmed": ("captured_unconfirmed", _bool),
395
+ }
396
+ SLOT_RULES: Dict[str, FieldRule] = {
397
+ "slot_id": ("slot_id", _id),
398
+ "start": ("start", _ts),
399
+ "end": ("end", _ts),
400
+ "duration": ("duration", _duration),
401
+ "price_amount": ("price_amount", _num),
402
+ "alternate_slot_ids": ("alternate_slot_ids", lambda v: _list(v, _id, 10) or []),
403
+ }
404
+ WINDOW_RULES: Dict[str, FieldRule] = {"from_": ("from", _ts), "to": ("to", _ts)}
405
+ LAST_ERROR_RULES: Dict[str, FieldRule] = {"code": ("code", _id), "message": EXCLUDE}
406
+
407
+
408
+ def _nested(rules: Dict[str, FieldRule]) -> Callable[[Any], Any]:
409
+ def project(obj: Any) -> Any:
410
+ if obj is None:
411
+ return None
412
+ out = {}
413
+ for name, rule in rules.items():
414
+ if rule is None:
415
+ continue
416
+ key, fn = rule
417
+ try:
418
+ out[key] = fn(getattr(obj, name, None))
419
+ except Exception:
420
+ out[key] = None
421
+ return out
422
+
423
+ return project
424
+
425
+
426
+ SESSION_RULES: Dict[str, FieldRule] = {
427
+ "id": ("id", _id),
428
+ "correlation_id": EXCLUDE, # top-level `correlation_id`
429
+ "created_at": ("created_at", _ts),
430
+ "user_request": EXCLUDE,
431
+ "schema_version": ("schema_version", _num),
432
+ "flow_phase": ("flow_phase", _id),
433
+ "business": ("business", _nested(BUSINESS_RULES)),
434
+ "service_timezone": ("service_timezone", _tz),
435
+ "buyer_timezone": ("buyer_timezone", _tz),
436
+ "buyer_timezone_source": ("buyer_timezone_source", _id),
437
+ "buyer_timezone_error": ("buyer_timezone_error", _id),
438
+ "timezone_difference_shown": ("timezone_difference_shown", _bool),
439
+ "slots_presented_dual_zone": ("slots_presented_dual_zone", _bool),
440
+ "calendar_gate_completed": ("calendar_gate_completed", _bool),
441
+ "calendar_state": ("calendar_state", _id),
442
+ "calendar_mode": ("calendar_mode", _id),
443
+ "calendar_route": ("calendar_route", _id),
444
+ "calendar_token_env": ("calendar_token_env", _id), # a variable name; the value is never stored
445
+ "link_auth_gate_completed": ("link_auth_gate_completed", _bool),
446
+ "availability_window": ("availability_window", _window),
447
+ "slots_presented": ("slots_presented", lambda v: _list(v, _slot, 50)),
448
+ "slot_id": ("slot_id", _id),
449
+ "slot_user_confirmed": ("slot_user_confirmed", _bool),
450
+ "service_id": ("service_id", _id),
451
+ "selection_mismatch": EXCLUDE,
452
+ "timezone_conflict_confirmed": ("timezone_conflict_confirmed", _bool),
453
+ "live_service": ("live_service", _live_service),
454
+ "payment_timing": ("payment_timing", _id),
455
+ "payment_route": ("payment_route", _id),
456
+ "selected_term_id": ("selected_term_id", _id),
457
+ "payment_schedules": ("payment_schedules", _terms),
458
+ "due_now_amount_minor": ("due_now_amount_minor", _num),
459
+ "payment_disclosures": ("payment_disclosures", _terms),
460
+ "disclosures_user_confirmed": ("disclosures_user_confirmed", _bool),
461
+ "total_confirmed_amount_minor": ("total_confirmed_amount_minor", _num),
462
+ "accepted_term": ("accepted_term", _terms),
463
+ "complete_checkout_idempotency_key": EXCLUDE,
464
+ "pending_booking_attempt": EXCLUDE,
465
+ "checkout_mode": ("checkout_mode", _id),
466
+ "supported_checkout_modes": ("supported_checkout_modes", lambda v: _list(v, _id, 10)),
467
+ "checkout_prereq_completed": ("checkout_prereq_completed", _bool),
468
+ "ucp_checkout_rest_base": ("ucp_checkout_rest_base", _url),
469
+ "ucp_checkout_session_id": ("ucp_checkout_session_id", _id),
470
+ "ucp_checkout_amount_minor": ("ucp_checkout_amount_minor", _num),
471
+ "ucp_merchant_quote": ("ucp_merchant_quote", _quote),
472
+ "ucp_checkout_links": EXCLUDE,
473
+ "ucp_checkout_policies": EXCLUDE,
474
+ "ucp_checkout_messages": ("ucp_checkout_messages", _messages),
475
+ "ucp_spt_network_id": ("ucp_spt_network_id", _id),
476
+ "ucp_protocol_version": ("ucp_protocol_version", _id),
477
+ "ucp_negotiated_capabilities": ("ucp_negotiated_capabilities", _capabilities),
478
+ "ucp_capabilities_authoritative": ("ucp_capabilities_authoritative", _bool),
479
+ "ucp_checkout_cancel_attempted": ("ucp_checkout_cancel_attempted", _bool),
480
+ "ucp_checkout_cancel_succeeded": ("ucp_checkout_cancel_succeeded", _bool),
481
+ "ucp_checkout_cancel_error": EXCLUDE,
482
+ "buyer_source": ("buyer_source", _label),
483
+ "buyer": EXCLUDE,
484
+ "booking": ("booking", _nested(BOOKING_RULES)),
485
+ "payment": ("payment", _nested(PAYMENT_RULES)),
486
+ "last_error": ("last_error", _last_error),
487
+ "activity_log": EXCLUDE, # top-level `activity`
488
+ "discover_query": EXCLUDE,
489
+ "discover_location_lat": EXCLUDE,
490
+ "discover_location_lng": EXCLUDE,
491
+ "discover_location_radius_km": ("discover_location_radius_km", _num),
492
+ "discover_registry_url": ("discover_registry_url", _url),
493
+ "discover_registry_request_body": EXCLUDE,
494
+ "discover_services": ("discover_services", _discover),
495
+ "flow_timings": ("flow_timings", _timings),
496
+ "human_gate_timings": ("human_gate_timings", _timings),
497
+ "skill_compliance_validated": ("skill_compliance_validated", _bool),
498
+ "reschedule_original_slot_id": ("reschedule_original_slot_id", _id),
499
+ "reschedule_slots": ("reschedule_slots", lambda v: _list(v, _slot, 50)),
500
+ "reschedule_slot_id": ("reschedule_slot_id", _id),
501
+ "reschedule_user_confirmed": ("reschedule_user_confirmed", _bool),
502
+ "support_report_declined": EXCLUDE,
503
+ "support_report_asked": EXCLUDE,
504
+ "support_report_bytes": EXCLUDE,
505
+ "support_report_recovery_command": EXCLUDE,
506
+ "support_report_send_failures": EXCLUDE,
507
+ # Addresses and the free-text note never leave the device; only whether they are set.
508
+ "search_area": (
509
+ "search_area_confirmed", lambda v: bool(isinstance(v, dict) and v.get("confirmed")),
510
+ ),
511
+ "pending_area": EXCLUDE,
512
+ "service_address": ("service_address_set", lambda v: bool(v)),
513
+ "service_address_label": EXCLUDE,
514
+ "area_resume_command": EXCLUDE,
515
+ "booking_notes": EXCLUDE,
516
+ "calendar_timezone": ("calendar_timezone", _tz),
517
+ }
518
+
519
+ # Dataclass -> its rules table, for the classification test.
520
+ CLASSIFIED = {
521
+ Session: SESSION_RULES,
522
+ Business: BUSINESS_RULES,
523
+ BookingRef: BOOKING_RULES,
524
+ PaymentRef: PAYMENT_RULES,
525
+ SlotRef: SLOT_RULES,
526
+ AvailabilityWindow: WINDOW_RULES,
527
+ LastError: LAST_ERROR_RULES,
528
+ }
529
+
530
+
531
+ def unclassified_fields() -> List[str]:
532
+ missing = []
533
+ for cls, rules in CLASSIFIED.items():
534
+ for f in dataclass_fields(cls):
535
+ if f.name not in rules:
536
+ missing.append(f"{cls.__name__}.{f.name}")
537
+ return missing
538
+
539
+
540
+ def project_session(session: Session) -> Dict[str, Any]:
541
+ out: Dict[str, Any] = {}
542
+ for name, rule in SESSION_RULES.items():
543
+ if rule is None:
544
+ continue
545
+ key, fn = rule
546
+ try:
547
+ out[key] = fn(getattr(session, name, None))
548
+ except Exception:
549
+ out[key] = None
550
+ business = out.get("business")
551
+ if isinstance(business, dict):
552
+ business["ucp_checkout"] = getattr(session, "checkout_mode", None) == "ucp_native"
553
+ return out
554
+
555
+
556
+ # ---------------------------------------------------------------------------
557
+ # Commands, frames, environment
558
+ # ---------------------------------------------------------------------------
559
+
560
+ def _root_command() -> Any:
561
+ from .main import app as main_app
562
+
563
+ return typer.main.get_command(main_app)
564
+
565
+
566
+ def _sanitize_one(tokens: List[str], root: Any) -> str:
567
+ out = ["booking-agent"]
568
+ rest = tokens[1:]
569
+ group = root
570
+ i = 0
571
+ while i < len(rest):
572
+ commands = getattr(group, "commands", None)
573
+ sub = commands.get(rest[i]) if isinstance(commands, dict) else None
574
+ if sub is None:
575
+ break
576
+ out.append(rest[i])
577
+ group = sub
578
+ i += 1
579
+ for tok in rest[i:]:
580
+ name = tok.split("=", 1)[0]
581
+ if _FLAG_RE.match(name):
582
+ out.append(name)
583
+ return " ".join(out)
584
+
585
+
586
+ def sanitize_command(text: Optional[str]) -> Optional[str]:
587
+ """Keep the subcommand path and flag names; drop every value."""
588
+ if not isinstance(text, str) or not text.strip():
589
+ return None
590
+ try:
591
+ root = _root_command()
592
+ except Exception:
593
+ root = None
594
+ parts = []
595
+ for chunk in text.split("&&"):
596
+ try:
597
+ tokens = shlex.split(chunk)
598
+ except ValueError:
599
+ tokens = chunk.split()
600
+ if tokens:
601
+ parts.append(_sanitize_one(tokens, root))
602
+ return " && ".join(parts)[:MAX_COMMAND_CHARS] or None
603
+
604
+
605
+ def _current_click_context() -> Any:
606
+ # Typer >= 0.27 vendors click as typer._click with its own context stack.
607
+ for module in ("typer._click.globals", "click.globals"):
608
+ try:
609
+ mod = __import__(module, fromlist=["get_current_context"])
610
+ ctx = mod.get_current_context(silent=True)
611
+ except Exception:
612
+ continue
613
+ if ctx is not None:
614
+ return ctx
615
+ return None
616
+
617
+
618
+ def current_command_path() -> Optional[str]:
619
+ try:
620
+ ctx = _current_click_context()
621
+ names = []
622
+ while ctx is not None and ctx.parent is not None:
623
+ names.append(ctx.info_name or "")
624
+ ctx = ctx.parent
625
+ return " ".join(reversed(names)) or None
626
+ except Exception:
627
+ return None
628
+
629
+
630
+ def _strip_flow(path: str) -> str:
631
+ path = path.strip()
632
+ return path[len("flow "):] if path.startswith("flow ") else path
633
+
634
+
635
+ def _qualname(exc: BaseException) -> Optional[str]:
636
+ cls = type(exc)
637
+ name = f"{cls.__module__}.{cls.__qualname__}"
638
+ return name if _QUALNAME_RE.match(name) else None
639
+
640
+
641
+ def _frame_file(filename: str) -> Optional[str]:
642
+ parts = PurePath(filename).parts
643
+ for marker in ("site-packages", "dist-packages"):
644
+ if marker in parts:
645
+ idx = len(parts) - 1 - parts[::-1].index(marker)
646
+ rest = parts[idx + 1:]
647
+ return "/".join(rest) if rest else None
648
+ for idx in range(len(parts) - 1, -1, -1):
649
+ if parts[idx] in _PROJECT_PACKAGES:
650
+ return "/".join(parts[idx:])
651
+ stdlib = sysconfig.get_paths().get("stdlib")
652
+ if stdlib and filename.startswith(stdlib + os.sep):
653
+ return "stdlib/" + PurePath(os.path.relpath(filename, stdlib)).as_posix()
654
+ return None
655
+
656
+
657
+ def _exception(exc: Optional[BaseException]) -> Optional[Dict[str, Any]]:
658
+ if exc is None:
659
+ return None
660
+ chain: List[Optional[str]] = []
661
+ seen = {id(exc)}
662
+ cur = exc.__cause__ or exc.__context__
663
+ while cur is not None and len(chain) < 5 and id(cur) not in seen:
664
+ seen.add(id(cur))
665
+ chain.append(_qualname(cur))
666
+ cur = cur.__cause__ or cur.__context__
667
+ frames = []
668
+ try:
669
+ for fs in traceback.extract_tb(exc.__traceback__)[-40:]:
670
+ file = _frame_file(fs.filename)
671
+ function = fs.name if isinstance(fs.name, str) and _QUALNAME_RE.match(fs.name) else None
672
+ code = (fs.line or "").strip()[:200] if file else None
673
+ frames.append({
674
+ "file": file or "<external>",
675
+ "line": fs.lineno,
676
+ "function": function,
677
+ "code": code or None,
678
+ })
679
+ except Exception:
680
+ frames = []
681
+ return {"type": _qualname(exc), "chain": chain, "frames": frames}
682
+
683
+
684
+ def _cli_build() -> Optional[str]:
685
+ from importlib.metadata import distribution
686
+
687
+ raw = distribution("booking-agent-cli").read_text("direct_url.json")
688
+ if not raw:
689
+ return None
690
+ commit = (json.loads(raw).get("vcs_info") or {}).get("commit_id")
691
+ return commit if isinstance(commit, str) and re.match(r"^[0-9a-f]{7,64}$", commit) else None
692
+
693
+
694
+ def _safe_call(fn: Callable[[], Any]) -> Any:
695
+ try:
696
+ return fn()
697
+ except Exception:
698
+ return None
699
+
700
+
701
+ def _env(session: Session) -> Dict[str, Any]:
702
+ def cli_version() -> Optional[str]:
703
+ from . import __version__
704
+
705
+ return _id(__version__)
706
+
707
+ def core_version() -> Optional[str]:
708
+ from booking_agent import __version__
709
+
710
+ return _id(__version__)
711
+
712
+ return {
713
+ "skill_version": SKILL_VERSION,
714
+ "cli_version": _safe_call(cli_version),
715
+ "cli_build": _safe_call(_cli_build),
716
+ "core_version": _safe_call(core_version),
717
+ "python": _safe_call(lambda: _id(platform.python_version())),
718
+ "os": _safe_call(lambda: _label(f"{platform.system()} {platform.release()}")),
719
+ "machine": _safe_call(lambda: _id(platform.machine())),
720
+ }
721
+
722
+
723
+ def _occurrences(session: Session, code: str) -> int:
724
+ return 1 + sum(
725
+ 1 for e in (session.activity_log or []) if e.kind == "error" and e.code == code
726
+ )
727
+
728
+
729
+ def _activity(session: Session) -> List[Dict[str, Any]]:
730
+ out = []
731
+ for entry in (session.activity_log or [])[-50:]:
732
+ kind = getattr(entry, "kind", None)
733
+ out.append({
734
+ "at": _ts(getattr(entry, "at", None)),
735
+ "kind": kind if kind in ("command", "error") else None,
736
+ "command": sanitize_command(getattr(entry, "text", None)) if kind == "command" else None,
737
+ "code": _id(getattr(entry, "code", None)),
738
+ })
739
+ return out
740
+
741
+
742
+ def _http() -> Optional[Dict[str, Any]]:
743
+ from booking_agent.http_trace import last_http_failure
744
+
745
+ return last_http_failure()
746
+
747
+
748
+ # ---------------------------------------------------------------------------
749
+ # Bundle
750
+ # ---------------------------------------------------------------------------
751
+
752
+ def _now() -> str:
753
+ return datetime.now(timezone.utc).isoformat()
754
+
755
+
756
+ def _minimal_bundle(code: str, build_errors: List[Dict[str, Any]]) -> str:
757
+ try:
758
+ created = _now()
759
+ except Exception:
760
+ created = "1970-01-01T00:00:00+00:00"
761
+ try:
762
+ from . import __version__ as cli_version
763
+ except Exception:
764
+ cli_version = None
765
+ return json.dumps({
766
+ "bundle_version": BUNDLE_VERSION,
767
+ "created_at": created,
768
+ "env": {"cli_version": _id(cli_version)},
769
+ "error": {"code": _id(code)},
770
+ "build_errors": build_errors[:10],
771
+ }, sort_keys=True, separators=(",", ":"))
772
+
773
+
774
+ def _serialize(bundle: Dict[str, Any]) -> str:
775
+ return json.dumps(bundle, sort_keys=True, separators=(",", ":"), default=str)
776
+
777
+
778
+ def _cap(bundle: Dict[str, Any]) -> str:
779
+ text = _serialize(bundle)
780
+ while len(text.encode("utf-8")) > MAX_BUNDLE_BYTES:
781
+ activity = bundle.get("activity")
782
+ exc = bundle.get("exception") or {}
783
+ session = bundle.get("session") or {}
784
+ if activity:
785
+ bundle["activity"] = activity[len(activity) // 2 + 1:] if len(activity) > 1 else []
786
+ elif exc.get("frames"):
787
+ frames = exc["frames"]
788
+ exc["frames"] = frames[len(frames) // 2 + 1:] if len(frames) > 1 else []
789
+ elif session.get("discover_services"):
790
+ session["discover_services"] = []
791
+ else:
792
+ raise ValueError("bundle too large")
793
+ text = _serialize(bundle)
794
+ return text
795
+
796
+
797
+ def build_bundle(
798
+ session: Session,
799
+ *,
800
+ code: str,
801
+ phase: Optional[str],
802
+ next_command: Optional[str],
803
+ reason: str,
804
+ exc: Optional[BaseException] = None,
805
+ ) -> str:
806
+ """Serialized report; never raises."""
807
+ build_errors: List[Dict[str, Any]] = []
808
+
809
+ def section(name: str, fn: Callable[[], Any]) -> Any:
810
+ try:
811
+ return fn()
812
+ except Exception as e: # noqa: BLE001
813
+ build_errors.append({"section": name, "type": type(e).__name__[:64]})
814
+ return None
815
+
816
+ try:
817
+ from booking_agent.http_trace import _redact_json_value
818
+
819
+ bundle: Dict[str, Any] = {
820
+ "bundle_version": BUNDLE_VERSION,
821
+ "created_at": _now(),
822
+ "correlation_id": section("correlation_id", lambda: _id(session.correlation_id)),
823
+ "env": section("env", lambda: _env(session)),
824
+ "error": section("error", lambda: {
825
+ "code": _id(code),
826
+ "phase": _id(phase),
827
+ "occurrences": _occurrences(session, code),
828
+ "offer_reason": reason,
829
+ "next_command_before_offer": sanitize_command(next_command),
830
+ }),
831
+ "exception": section("exception", lambda: _exception(exc)),
832
+ "http": section("http", _http),
833
+ "session": section("session", lambda: project_session(session)),
834
+ "activity": section("activity", lambda: _activity(session)),
835
+ }
836
+ bundle["build_errors"] = build_errors
837
+ if bundle["env"] is None or bundle["error"] is None:
838
+ return _minimal_bundle(code, build_errors)
839
+ text = _cap(_redact_json_value(bundle))
840
+ if validate_bundle(json.loads(text)):
841
+ return _minimal_bundle(code, build_errors + [{"section": "schema", "type": "SchemaMismatch"}])
842
+ return text
843
+ except Exception as e: # noqa: BLE001
844
+ return _minimal_bundle(code, build_errors + [{"section": "serialize", "type": type(e).__name__[:64]}])
845
+
846
+
847
+ # ---------------------------------------------------------------------------
848
+ # Offer policy
849
+ # ---------------------------------------------------------------------------
850
+
851
+ def _forbidden_after_terminal_stop(session: Session, command_path: Optional[str]) -> bool:
852
+ if session.flow_phase != "FAILED" or not command_path:
853
+ return False
854
+ last_stop = next(
855
+ (e.code for e in reversed(session.activity_log or [])
856
+ if e.kind == "error" and e.code in TERMINAL_STOP_FORBIDDEN),
857
+ None,
858
+ )
859
+ if last_stop is None:
860
+ return False
861
+ path = _strip_flow(command_path)
862
+ return any(_strip_flow(cmd) == path for cmd in TERMINAL_STOP_FORBIDDEN[last_stop])
863
+
864
+
865
+ def _repeated(session: Session, code: str) -> bool:
866
+ log = session.activity_log or []
867
+ last = max((i for i, e in enumerate(log) if e.kind == "error" and e.code == code), default=None)
868
+ if last is None:
869
+ return False
870
+ return any(e.kind == "command" for e in log[last + 1:])
871
+
872
+
873
+ def _command_path_of(next_command: Optional[str]) -> Optional[str]:
874
+ if not isinstance(next_command, str):
875
+ return None
876
+ words = []
877
+ for word in next_command.split("&&")[0].split():
878
+ if word.startswith("-") or word.startswith("<"):
879
+ break
880
+ words.append(word)
881
+ if words and words[0] == "booking-agent":
882
+ words = words[1:]
883
+ return " ".join(words) or None
884
+
885
+
886
+ def _is_wait_loop(next_command: Optional[str], command_path: Optional[str]) -> bool:
887
+ """A polling command that tells the agent to poll again is still waiting."""
888
+ path = _command_path_of(next_command)
889
+ if not path or not command_path:
890
+ return False
891
+ path = _strip_flow(path)
892
+ return path in POLL_COMMANDS and path == _strip_flow(command_path)
893
+
894
+
895
+ def offer_reason(
896
+ session: Session,
897
+ code: str,
898
+ next_command: Optional[str],
899
+ command_path: Optional[str] = None,
900
+ ) -> Optional[str]:
901
+ if code in NEVER_OFFER_CODES:
902
+ return None
903
+ if session.support_report_declined or session.support_report_bytes:
904
+ return None
905
+ if _forbidden_after_terminal_stop(session, command_path):
906
+ return None
907
+ if code == "internal_error":
908
+ return "internal_error"
909
+ if code == "payment_captured_booking_unconfirmed" and not next_command:
910
+ return "captured_no_next"
911
+ # An undocumented code that still hands back a recovery command is a planned
912
+ # CLI outcome; only a dead end counts as unknown.
913
+ if code not in EXPECTED_CODES and not next_command:
914
+ return "unknown_code"
915
+ if code in STILL_WORKING_CODES or _is_wait_loop(next_command, command_path):
916
+ return None
917
+ if _repeated(session, code):
918
+ return "repeated"
919
+ return None
920
+
921
+
922
+ def maybe_offer(
923
+ session: Session,
924
+ code: str,
925
+ next_command: Optional[str],
926
+ phase: Optional[str],
927
+ exc: Optional[BaseException] = None,
928
+ ) -> bool:
929
+ """Freeze a report on ``session`` when the policy offers one. Caller saves."""
930
+ reason = offer_reason(session, code, next_command, current_command_path())
931
+ if reason is None:
932
+ return False
933
+ payload = build_bundle(
934
+ session, code=code, phase=phase, next_command=next_command, reason=reason, exc=exc,
935
+ )
936
+ session.support_report_bytes = payload
937
+ session.support_report_recovery_command = next_command
938
+ session.support_report_asked = False
939
+ session.support_report_send_failures = 0
940
+ return True
941
+
942
+
943
+ # ---------------------------------------------------------------------------
944
+ # Commands
945
+ # ---------------------------------------------------------------------------
946
+
947
+ _CORRUPT = object()
948
+
949
+
950
+ def _snapshot(session: Session) -> Any:
951
+ raw = session.support_report_bytes
952
+ if not raw:
953
+ return None
954
+ try:
955
+ parsed = json.loads(raw)
956
+ except (TypeError, ValueError):
957
+ return _CORRUPT
958
+ if not isinstance(parsed, dict) or validate_bundle(parsed):
959
+ return _CORRUPT
960
+ return parsed
961
+
962
+
963
+ def _restore(session: Session, *, declined: bool = False) -> Optional[str]:
964
+ from .engine import recommend_next
965
+
966
+ recovery = session.support_report_recovery_command
967
+ session.support_report_bytes = None
968
+ session.support_report_asked = False
969
+ session.support_report_recovery_command = None
970
+ session.support_report_send_failures = 0
971
+ if declined:
972
+ session.support_report_declined = True
973
+ save_session(session, write_support_report=True)
974
+ return recovery or recommend_next(session)
975
+
976
+
977
+ def _emit_unavailable(session: Session) -> "typer.Exit":
978
+ next_cmd = _restore(session)
979
+ emit_error(
980
+ session.flow_phase,
981
+ code="support_report_unavailable",
982
+ message=(
983
+ "The saved note could not be read, so there is nothing to send. Do not ask "
984
+ "the user about it. Continue with next_command."
985
+ ),
986
+ next_command=next_cmd,
987
+ )
988
+ return typer.Exit(code=1)
989
+
990
+
991
+ def consent_output(session: Session, report: Dict[str, Any]) -> AgentOutput:
992
+ """The consent gate shown by `support-report` and by `flow status`."""
993
+ first_ask = not session.support_report_asked
994
+ if first_ask:
995
+ session.support_report_asked = True
996
+ save_session(session, write_support_report=True)
997
+ message = RETRY_CONSENT_MESSAGE if session.support_report_send_failures else CONSENT_MESSAGE
998
+ prompt = (
999
+ "STOP. Show user_message to the user exactly as written and wait for their "
1000
+ "answer. If they agree, run next_command. If they say no, run "
1001
+ f"`{DECLINE_COMMAND}`. Do not write, edit, or send the note yourself."
1002
+ )
1003
+ else:
1004
+ message = None
1005
+ prompt = (
1006
+ "You already asked the user whether to send the note. Do not ask again. Wait "
1007
+ "for their answer: if they agree, run next_command; if they say no, run "
1008
+ f"`{DECLINE_COMMAND}`."
1009
+ )
1010
+ return AgentOutput(
1011
+ phase=session.flow_phase,
1012
+ awaiting_user=AWAITING_SUPPORT_REPORT_CONSENT,
1013
+ user_message=message,
1014
+ prompt=prompt,
1015
+ next_command=SEND_COMMAND,
1016
+ forbidden=[
1017
+ "Sending the note without the user's yes",
1018
+ "Writing, editing, or emailing the note yourself",
1019
+ ],
1020
+ data={"report": report, "decline_command": DECLINE_COMMAND},
1021
+ )
1022
+
1023
+
1024
+ def status_gate(session: Session) -> Optional[AgentOutput]:
1025
+ """Consent gate for `flow status` while a note waits for the user's answer."""
1026
+ try:
1027
+ report = _snapshot(session)
1028
+ if report is None:
1029
+ return None
1030
+ if report is _CORRUPT:
1031
+ _restore(session)
1032
+ return None
1033
+ if session.support_report_send_failures:
1034
+ return None
1035
+ return consent_output(session, report)
1036
+ except Exception:
1037
+ return None
1038
+
1039
+
1040
+ def _load_or_exit() -> Session:
1041
+ session = load_session()
1042
+ if session is None:
1043
+ emit_error(
1044
+ "INIT",
1045
+ code="no_support_report",
1046
+ message="No active booking, so there is no note to send.",
1047
+ )
1048
+ raise typer.Exit(code=1)
1049
+ return session
1050
+
1051
+
1052
+ def _no_pending(session: Session) -> None:
1053
+ from .engine import recommend_next
1054
+
1055
+ emit(AgentOutput(
1056
+ phase=session.flow_phase,
1057
+ prompt="There is no note waiting to be sent. Continue with next_command.",
1058
+ next_command=recommend_next(session),
1059
+ data={"sent": False, "pending": False},
1060
+ ))
1061
+
1062
+
1063
+ def _apps_script_echo_url(url: str, response: httpx.Response) -> Optional[str]:
1064
+ if response.status_code not in (302, 303):
1065
+ return None
1066
+ location = response.headers.get("location") or ""
1067
+ try:
1068
+ origin, target = urlsplit(url), urlsplit(location)
1069
+ except ValueError:
1070
+ return None
1071
+ if (
1072
+ origin.scheme == "https" and origin.hostname == _APPS_SCRIPT_HOST
1073
+ and target.scheme == "https" and target.hostname == _APPS_SCRIPT_ECHO_HOST
1074
+ ):
1075
+ return location
1076
+ return None
1077
+
1078
+
1079
+ def _http_failure(status: int) -> Optional[str]:
1080
+ return None if 200 <= status < 300 else f"http_{status}"
1081
+
1082
+
1083
+ def _post_report(
1084
+ url: str, body: bytes, *, transport: Optional[httpx.BaseTransport] = None,
1085
+ ) -> Optional[str]:
1086
+ """POST the note; returns None when delivered, else a short failure label.
1087
+
1088
+ The label is ``http_<status>`` for a real HTTP failure, the ingest's own
1089
+ rejection reason when it is one of ``INGEST_REJECTION_REASONS``, and
1090
+ ``rejected`` for any other refusal or an unreadable reply.
1091
+ """
1092
+ with httpx.Client(timeout=SEND_TIMEOUT_S, follow_redirects=False, transport=transport) as client:
1093
+ response = client.post(url, content=body, headers={"content-type": "application/json"})
1094
+ echo = _apps_script_echo_url(url, response)
1095
+ if echo is None:
1096
+ return _http_failure(response.status_code)
1097
+ reply = client.get(echo)
1098
+ if reply.status_code != 200:
1099
+ return f"http_{reply.status_code}"
1100
+ try:
1101
+ parsed = reply.json()
1102
+ except ValueError:
1103
+ return "rejected"
1104
+ if not isinstance(parsed, dict):
1105
+ return "rejected"
1106
+ if parsed.get("accepted") is True:
1107
+ return None
1108
+ reason = parsed.get("reason")
1109
+ return reason if reason in INGEST_REJECTION_REASONS else "rejected"
1110
+
1111
+
1112
+ support_app = typer.Typer(
1113
+ help="Consented note to the booking-assistant team after an unrecoverable failure.",
1114
+ invoke_without_command=True,
1115
+ )
1116
+
1117
+
1118
+ @support_app.callback()
1119
+ def support_report(ctx: typer.Context) -> None:
1120
+ """Show the saved note and ask the user whether to send it."""
1121
+ if ctx.invoked_subcommand:
1122
+ return
1123
+ session = _load_or_exit()
1124
+ report = _snapshot(session)
1125
+ if report is None:
1126
+ _no_pending(session)
1127
+ return
1128
+ if report is _CORRUPT:
1129
+ raise _emit_unavailable(session)
1130
+ emit(consent_output(session, report))
1131
+
1132
+
1133
+ @support_app.command("send")
1134
+ def support_report_send(
1135
+ user_confirmed: bool = typer.Option(
1136
+ False, "--user-confirmed", help="The user agreed to send exactly this note.",
1137
+ ),
1138
+ ) -> None:
1139
+ """Send the saved note, unchanged, after the user agreed."""
1140
+ session = _load_or_exit()
1141
+ if not user_confirmed:
1142
+ emit_error(
1143
+ session.flow_phase,
1144
+ code="support_report_not_confirmed",
1145
+ message=(
1146
+ "Pass --user-confirmed only after the user said yes to sending the note. "
1147
+ f"Ask them with `{SUPPORT_REPORT_COMMAND}`."
1148
+ ),
1149
+ next_command=SUPPORT_REPORT_COMMAND,
1150
+ )
1151
+ raise typer.Exit(code=1)
1152
+ report = _snapshot(session)
1153
+ if report is None:
1154
+ _no_pending(session)
1155
+ return
1156
+ if report is _CORRUPT:
1157
+ raise _emit_unavailable(session)
1158
+ body = session.support_report_bytes.encode("utf-8")
1159
+ try:
1160
+ failure = _post_report(DEFAULT_SUPPORT_INGEST_URL, body)
1161
+ except Exception as e: # noqa: BLE001
1162
+ failure = type(e).__name__
1163
+ if failure is None:
1164
+ next_cmd = _restore(session)
1165
+ emit(AgentOutput(
1166
+ phase=session.flow_phase,
1167
+ user_message=SENT_MESSAGE,
1168
+ prompt="The note was sent. Show user_message, then continue with next_command.",
1169
+ next_command=next_cmd,
1170
+ data={"sent": True},
1171
+ ))
1172
+ return
1173
+ session.support_report_send_failures += 1
1174
+ if session.support_report_send_failures >= MAX_SEND_ATTEMPTS:
1175
+ next_cmd = _restore(session)
1176
+ else:
1177
+ session.support_report_asked = False
1178
+ save_session(session, write_support_report=True)
1179
+ next_cmd = session.support_report_recovery_command
1180
+ if not next_cmd:
1181
+ from .engine import recommend_next
1182
+
1183
+ next_cmd = recommend_next(session)
1184
+ emit_error(
1185
+ session.flow_phase,
1186
+ code="support_report_send_failed",
1187
+ message=(
1188
+ f"The note could not be delivered ({failure}). Show user_message, then "
1189
+ "continue with next_command. Do not try to send it again unless the user asks."
1190
+ ),
1191
+ user_message=SEND_FAILED_MESSAGE,
1192
+ next_command=next_cmd,
1193
+ )
1194
+ raise typer.Exit(code=1)
1195
+
1196
+
1197
+ @support_app.command("decline")
1198
+ def support_report_decline() -> None:
1199
+ """The user said no: drop the note and never offer again in this booking."""
1200
+ session = _load_or_exit()
1201
+ next_cmd = _restore(session, declined=True)
1202
+ emit(AgentOutput(
1203
+ phase=session.flow_phase,
1204
+ prompt=(
1205
+ "The user does not want to send the note. Do not ask again. Continue with "
1206
+ "next_command."
1207
+ ),
1208
+ next_command=next_cmd,
1209
+ data={"sent": False, "declined": True},
1210
+ ))