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,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
|
+
))
|