@fortrip/agent-contract 2.0.0 → 3.0.0
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.
- package/README.md +18 -13
- package/dist/index.d.ts +2625 -268
- package/dist/index.js +2272 -532
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -55,10 +55,10 @@ function passesLuhn(digits) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// src/commands.ts
|
|
58
|
-
import { z as
|
|
58
|
+
import { z as z6 } from "zod";
|
|
59
59
|
|
|
60
60
|
// src/inputs.ts
|
|
61
|
-
import { z as
|
|
61
|
+
import { z as z5 } from "zod";
|
|
62
62
|
|
|
63
63
|
// src/dates.ts
|
|
64
64
|
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -79,243 +79,391 @@ function addIsoDateDays(date, days) {
|
|
|
79
79
|
return value.toISOString().slice(0, 10);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// src/
|
|
82
|
+
// src/conventions/ids.ts
|
|
83
83
|
import { z } from "zod";
|
|
84
|
+
function prefixed(prefix, name) {
|
|
85
|
+
const error = `${name} must be ${prefix}_ followed by 20 letters or digits`;
|
|
86
|
+
return z.string({ error }).regex(new RegExp(`^${prefix}_[0-9A-Za-z]{20}$`), { error });
|
|
87
|
+
}
|
|
88
|
+
var hotelId = prefixed("htl", "hotelId");
|
|
89
|
+
var offerId = prefixed("off", "offerId");
|
|
90
|
+
var orderId = prefixed("ord", "orderId");
|
|
91
|
+
var placeId = z.string({ error: "placeId must be plc_ followed by 1 to 12 digits" }).regex(/^plc_\d{1,12}$/, {
|
|
92
|
+
error: "placeId must be plc_ followed by 1 to 12 digits"
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// src/conventions/money.ts
|
|
96
|
+
import { z as z2 } from "zod";
|
|
97
|
+
var amountError = "amount must be a decimal string such as 132.40";
|
|
98
|
+
var currencyError = "currency must be a 3-letter uppercase ISO 4217 code";
|
|
99
|
+
var money = z2.looseObject({
|
|
100
|
+
amount: z2.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, {
|
|
101
|
+
error: amountError
|
|
102
|
+
}),
|
|
103
|
+
currency: z2.string({ error: currencyError }).regex(/^[A-Z]{3}$/, {
|
|
104
|
+
error: currencyError
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
var usdMoney = z2.strictObject(
|
|
108
|
+
{
|
|
109
|
+
amount: z2.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, { error: amountError }).describe("Decimal string such as 132.40, never a number."),
|
|
110
|
+
currency: z2.literal("USD", { error: "currency must be USD" }).describe("Always USD in a request.")
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys.join(", ")}` : "Expected a Money object"
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
function formatMoney(m) {
|
|
117
|
+
return `${m.currency} ${m.amount}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/conventions/time.ts
|
|
121
|
+
import { z as z3 } from "zod";
|
|
122
|
+
var instant = z3.string({ error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z" }).regex(/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z$/, {
|
|
123
|
+
error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z"
|
|
124
|
+
}).refine((s) => isIsoDate(s.slice(0, 10)), { error: "must be a real instant" });
|
|
125
|
+
var localDate = z3.string({ error: "must be a real yyyy-MM-dd calendar date" }).refine(isIsoDate, { error: "must be a real yyyy-MM-dd calendar date" });
|
|
126
|
+
var localTime = z3.string({ error: "must be a local time such as 14:00" }).regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, {
|
|
127
|
+
error: "must be a local time such as 14:00"
|
|
128
|
+
});
|
|
129
|
+
function isIanaZone(s) {
|
|
130
|
+
if (!/^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/.test(s)) return false;
|
|
131
|
+
try {
|
|
132
|
+
new Intl.DateTimeFormat("en", { timeZone: s });
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
var timeZone = z3.string({ error: "must be an IANA time zone such as Asia/Shanghai" }).refine(isIanaZone, { error: "must be an IANA time zone such as Asia/Shanghai" });
|
|
139
|
+
|
|
140
|
+
// src/fields.ts
|
|
141
|
+
import { z as z4 } from "zod";
|
|
84
142
|
function strictInput(shape) {
|
|
85
|
-
return
|
|
143
|
+
return z4.strictObject(shape, {
|
|
86
144
|
error: (issue) => issue.code === "unrecognized_keys" ? `Unknown argument(s): ${issue.keys.join(", ")}` : "Expected a JSON object"
|
|
87
145
|
});
|
|
88
146
|
}
|
|
89
147
|
function wholeNumber(name, min, max) {
|
|
90
148
|
const error = `${name} must be a whole number from ${min} to ${max}`;
|
|
91
|
-
return
|
|
92
|
-
}
|
|
93
|
-
function positiveId(name) {
|
|
94
|
-
const error = `${name} must be a positive whole number`;
|
|
95
|
-
return z.number({ error }).int({ error }).positive({ error });
|
|
96
|
-
}
|
|
97
|
-
function isoDate(name) {
|
|
98
|
-
const error = `${name} must be a real yyyy-MM-dd calendar date`;
|
|
99
|
-
return z.string({ error }).refine(isIsoDate, { error });
|
|
149
|
+
return z4.number({ error }).int({ error }).min(min, { error }).max(max, { error });
|
|
100
150
|
}
|
|
101
151
|
function nonEmpty(name) {
|
|
102
152
|
const error = `${name} must be a non-empty string`;
|
|
103
|
-
return
|
|
153
|
+
return z4.string({ error }).min(1, { error });
|
|
104
154
|
}
|
|
105
155
|
function text(name, min, max) {
|
|
106
156
|
const error = `${name} must be text of ${min} to ${max} characters`;
|
|
107
|
-
return
|
|
157
|
+
return z4.string({ error }).min(min, { error }).max(max, { error });
|
|
108
158
|
}
|
|
109
159
|
function oneOf(name, values) {
|
|
110
|
-
return
|
|
160
|
+
return z4.enum(values, {
|
|
111
161
|
error: `${name} must be one of: ${values.join(", ")}`
|
|
112
162
|
});
|
|
113
163
|
}
|
|
114
164
|
function flag(name) {
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
function childrenAges(max) {
|
|
118
|
-
const error = `childrenAges must list each child's age (0 to 17) in one room, at most ${max}`;
|
|
119
|
-
return z.array(wholeNumber("Each child's age", 0, 17), { error }).max(max, { error });
|
|
165
|
+
return z4.boolean({ error: `${name} must be true or false` });
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
// src/limits.ts
|
|
123
169
|
var LIMITS = {
|
|
124
170
|
rooms: 8,
|
|
125
171
|
adultsPerRoom: 8,
|
|
126
|
-
childrenPerRoom:
|
|
172
|
+
childrenPerRoom: 4,
|
|
127
173
|
childAgeMax: 17,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
174
|
+
nightsFortrip: 30,
|
|
175
|
+
nightsSupplier: 15,
|
|
176
|
+
offersHotels: 15,
|
|
177
|
+
offersPerHotelDefault: 5,
|
|
178
|
+
offersPerHotelMax: 20,
|
|
179
|
+
pageSizeDefault: 20,
|
|
180
|
+
pageSizeMax: 50,
|
|
181
|
+
textLength: 128,
|
|
182
|
+
radiusMetersMax: 8e4,
|
|
183
|
+
radiusMetersDefault: 8e3,
|
|
184
|
+
amenityFilters: 9,
|
|
135
185
|
guestName: 60,
|
|
136
|
-
|
|
186
|
+
contactFirstName: 59,
|
|
187
|
+
contactLastName: 59,
|
|
137
188
|
contactPhoneMin: 3,
|
|
138
189
|
contactPhoneMax: 40,
|
|
139
190
|
specialRequests: 500,
|
|
140
|
-
|
|
191
|
+
poolLimit: 3e3,
|
|
192
|
+
candidatesShown: 8,
|
|
193
|
+
candidatesCounted: 200,
|
|
194
|
+
localitiesShown: 8
|
|
141
195
|
};
|
|
142
|
-
var
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
"
|
|
196
|
+
var DISPLAY_CURRENCIES = ["USD", "CNY"];
|
|
197
|
+
var SUPPORT_EMAIL = "info@fortrip.ai";
|
|
198
|
+
|
|
199
|
+
// src/vocabulary.ts
|
|
200
|
+
var FILTERABLE_AMENITIES = [
|
|
201
|
+
"parking",
|
|
202
|
+
"wifi",
|
|
203
|
+
"laundry",
|
|
204
|
+
"restaurant",
|
|
205
|
+
"room_service",
|
|
206
|
+
"bar",
|
|
207
|
+
"non_smoking",
|
|
208
|
+
"business_center",
|
|
209
|
+
"pool"
|
|
210
|
+
];
|
|
211
|
+
var AMENITY_CODES = [
|
|
212
|
+
...FILTERABLE_AMENITIES,
|
|
213
|
+
"airport_shuttle",
|
|
214
|
+
"spa",
|
|
215
|
+
"gym",
|
|
216
|
+
"accessible",
|
|
217
|
+
"ev_charging",
|
|
218
|
+
"air_conditioning",
|
|
219
|
+
"pets_allowed",
|
|
220
|
+
"kitchen",
|
|
221
|
+
"other"
|
|
152
222
|
];
|
|
153
|
-
var CURRENCIES = ["CNY", "USD"];
|
|
154
223
|
var SEARCH_SORTS = [
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
224
|
+
"distance",
|
|
225
|
+
"relevance",
|
|
226
|
+
"stars_desc",
|
|
227
|
+
"price_asc",
|
|
228
|
+
"price_desc"
|
|
160
229
|
];
|
|
161
|
-
var SUPPORT_EMAIL = "info@fortrip.ai";
|
|
162
230
|
|
|
163
231
|
// src/inputs.ts
|
|
164
|
-
var
|
|
165
|
-
|
|
166
|
-
var DEFAULT_MAX_ROOMS_PER_HOTEL = 10;
|
|
167
|
-
var stayDescriptions = {
|
|
168
|
-
checkIn: "Check-in date, yyyy-MM-dd.",
|
|
169
|
-
checkOut: `Check-out date, yyyy-MM-dd. Stays are 1 to ${LIMITS.nights} nights.`
|
|
170
|
-
};
|
|
171
|
-
var displayCurrency = oneOf("currency", CURRENCIES).default("CNY").describe(
|
|
172
|
-
"Currency to show prices in. Prices are converted from the supplier's at Stripe's exchange rate; the response's `fx` says how."
|
|
232
|
+
var locale = oneOf("locale", ["en", "zh"]).describe(
|
|
233
|
+
"Language for names and text in the response: en or zh."
|
|
173
234
|
);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
var
|
|
181
|
-
var
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cityCode: positiveId("cityCode").optional().describe("Catalogue city code, as returned in `hotels[].cityCode`."),
|
|
186
|
-
near: strictInput({
|
|
187
|
-
lat: bounded("near.lat", -90, 90).describe("Latitude."),
|
|
188
|
-
lng: bounded("near.lng", -180, 180).describe("Longitude."),
|
|
189
|
-
radiusKm: z2.number({ error: radiusError }).positive({ error: radiusError }).max(LIMITS.nearRadiusKm, { error: radiusError }).describe("Radius in kilometres.")
|
|
190
|
-
}).optional().describe("Search around a point."),
|
|
191
|
-
starMin: bounded("starMin", 0, 5).optional().describe("Lowest star rating to include."),
|
|
192
|
-
starMax: bounded("starMax", 0, 5).optional().describe("Highest star rating to include."),
|
|
193
|
-
facilities: z2.array(
|
|
194
|
-
z2.string({ error: facilitiesError }).trim().min(1, { error: facilitiesError }).max(FACILITY_NAME_MAX, { error: facilitiesError }),
|
|
195
|
-
{ error: facilitiesError }
|
|
196
|
-
).min(1, { error: facilitiesError }).max(LIMITS.facilities, { error: facilitiesError }).transform((names) => [...new Set(names)]).optional().describe(
|
|
197
|
-
"Facilities every hotel must have. Names must match the catalogue exactly (case-insensitive), e.g. `Swimming pool`, `Parking`."
|
|
198
|
-
),
|
|
199
|
-
checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
|
|
200
|
-
checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
|
|
201
|
-
currency: displayCurrency,
|
|
202
|
-
priceMin: z2.number({ error: "priceMin must be a number, 0 or more" }).nonnegative({ error: "priceMin must be a number, 0 or more" }).optional().describe(
|
|
203
|
-
"Lowest average nightly price, in `currency`. Applied to indicative prices."
|
|
235
|
+
var displayCurrency = oneOf("displayCurrency", [
|
|
236
|
+
"USD",
|
|
237
|
+
"CNY"
|
|
238
|
+
]).describe(
|
|
239
|
+
"Currency to show prices in, USD or CNY. It never changes what is charged: payment is in USD."
|
|
240
|
+
);
|
|
241
|
+
var cursor = nonEmpty("cursor");
|
|
242
|
+
var nightsError = (name) => `${name} must be after the check-in date and at most ${LIMITS.nightsFortrip} nights later`;
|
|
243
|
+
var stay = strictInput({
|
|
244
|
+
checkIn: localDate.describe(
|
|
245
|
+
"Check-in date in the hotel's calendar, yyyy-MM-dd."
|
|
204
246
|
),
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"Result order. `recommended` sorts by distance with `near`, by relevance with `query`, otherwise by catalogue weight. `distance` requires `near`."
|
|
247
|
+
checkOut: localDate.describe(
|
|
248
|
+
`Check-out date in the hotel's calendar, yyyy-MM-dd. Stays are 1 to ${LIMITS.nightsFortrip} nights.`
|
|
208
249
|
),
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
if (input.priceMin !== void 0 && input.priceMax !== void 0 && input.priceMin > input.priceMax) {
|
|
234
|
-
ctx.addIssue({
|
|
235
|
-
code: "custom",
|
|
236
|
-
path: ["priceMax"],
|
|
237
|
-
message: "priceMax must be >= priceMin"
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
241
|
-
const earliest = addIsoDateDays((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), -1);
|
|
242
|
-
if (input.checkIn < earliest) {
|
|
243
|
-
ctx.addIssue({
|
|
244
|
-
code: "custom",
|
|
245
|
-
path: ["checkIn"],
|
|
246
|
-
message: "checkIn must not be in the past"
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
250
|
-
if (nights < 1) {
|
|
251
|
-
ctx.addIssue({
|
|
252
|
-
code: "custom",
|
|
253
|
-
path: ["checkOut"],
|
|
254
|
-
message: "checkOut must be after checkIn"
|
|
255
|
-
});
|
|
256
|
-
} else if (nights > LIMITS.nights) {
|
|
250
|
+
rooms: z5.array(
|
|
251
|
+
strictInput({
|
|
252
|
+
adults: wholeNumber("adults", 1, LIMITS.adultsPerRoom).describe(
|
|
253
|
+
"Adults in this room."
|
|
254
|
+
),
|
|
255
|
+
childrenAges: z5.array(wholeNumber("Each child's age", 0, LIMITS.childAgeMax), {
|
|
256
|
+
error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
|
|
257
|
+
}).max(LIMITS.childrenPerRoom, {
|
|
258
|
+
error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
|
|
259
|
+
}).describe(
|
|
260
|
+
"Age of each child in this room, 0 to 17. An empty list means no children."
|
|
261
|
+
)
|
|
262
|
+
}),
|
|
263
|
+
{ error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
|
|
264
|
+
).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
|
|
265
|
+
error: `rooms must list 1 to ${LIMITS.rooms} rooms`
|
|
266
|
+
}).describe(
|
|
267
|
+
`The rooms wanted, 1 to ${LIMITS.rooms}, each with its own party.`
|
|
268
|
+
)
|
|
269
|
+
}).superRefine((value, ctx) => {
|
|
270
|
+
if (!isIsoDate(value.checkIn) || !isIsoDate(value.checkOut)) return;
|
|
271
|
+
const nights = countIsoDateDays(value.checkIn, value.checkOut);
|
|
272
|
+
if (nights < 1 || nights > LIMITS.nightsFortrip) {
|
|
257
273
|
ctx.addIssue({
|
|
258
274
|
code: "custom",
|
|
259
275
|
path: ["checkOut"],
|
|
260
|
-
message:
|
|
276
|
+
message: nightsError("checkOut")
|
|
261
277
|
});
|
|
262
278
|
}
|
|
263
279
|
});
|
|
264
|
-
var
|
|
265
|
-
|
|
280
|
+
var countryCode = z5.string({ error: "countryCode must be an ISO 3166-1 alpha-2 code" }).regex(/^[A-Z]{2}$/, {
|
|
281
|
+
error: "countryCode must be 2 uppercase letters (ISO 3166-1 alpha-2)"
|
|
282
|
+
});
|
|
283
|
+
var radiusMeters = wholeNumber("radiusMeters", 1, LIMITS.radiusMetersMax).optional().describe(
|
|
284
|
+
`Search radius around the destination in meters, 1 to ${LIMITS.radiusMetersMax}. Left out, the service picks one.`
|
|
285
|
+
);
|
|
286
|
+
function bounded(name, min, max) {
|
|
287
|
+
const error = `${name} must be a number from ${min} to ${max}`;
|
|
288
|
+
return z5.number({ error }).min(min, { error }).max(max, { error });
|
|
289
|
+
}
|
|
290
|
+
var KINDS = ["place_name", "place", "point", "country", "locality"];
|
|
291
|
+
var destination = z5.discriminatedUnion(
|
|
292
|
+
"kind",
|
|
293
|
+
[
|
|
294
|
+
strictInput({
|
|
295
|
+
kind: z5.literal("place_name", { error: "kind must be place_name" }).describe("A place named in words, with its country."),
|
|
296
|
+
name: text("name", 1, LIMITS.textLength).describe(
|
|
297
|
+
"The place name, in any language, e.g. Tokyo or \u4E1C\u4EAC."
|
|
298
|
+
),
|
|
299
|
+
countryCode: countryCode.describe(
|
|
300
|
+
"ISO 3166-1 alpha-2 country the place is in, e.g. JP."
|
|
301
|
+
),
|
|
302
|
+
radiusMeters
|
|
303
|
+
}),
|
|
304
|
+
strictInput({
|
|
305
|
+
kind: z5.literal("place", { error: "kind must be place" }).describe("A place id from an earlier response."),
|
|
306
|
+
placeId: placeId.describe("placeId from an earlier response."),
|
|
307
|
+
radiusMeters
|
|
308
|
+
}),
|
|
309
|
+
strictInput({
|
|
310
|
+
kind: z5.literal("point", { error: "kind must be point" }).describe("A point on the map."),
|
|
311
|
+
location: strictInput({
|
|
312
|
+
lat: bounded("lat", -90, 90).describe("Latitude, WGS84."),
|
|
313
|
+
lng: bounded("lng", -180, 180).describe("Longitude, WGS84.")
|
|
314
|
+
}).describe("The point, WGS84."),
|
|
315
|
+
radiusMeters
|
|
316
|
+
}),
|
|
317
|
+
strictInput({
|
|
318
|
+
kind: z5.literal("country", { error: "kind must be country" }).describe("A whole country."),
|
|
319
|
+
countryCode: countryCode.describe("ISO 3166-1 alpha-2 country, e.g. JP.")
|
|
320
|
+
}),
|
|
321
|
+
strictInput({
|
|
322
|
+
kind: z5.literal("locality", { error: "kind must be locality" }).describe("A locality (hub) listed in an earlier search response."),
|
|
323
|
+
placeId: placeId.describe("placeId of the locality."),
|
|
324
|
+
radiusMeters
|
|
325
|
+
})
|
|
326
|
+
],
|
|
266
327
|
{
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
},
|
|
270
|
-
{ error: "Expected a JSON object" }
|
|
328
|
+
error: `destination.kind must be one of: ${KINDS.join(", ")}`
|
|
329
|
+
}
|
|
271
330
|
);
|
|
272
|
-
var
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
331
|
+
var textQuery = z5.string({
|
|
332
|
+
error: `text must be text of 1 to ${LIMITS.textLength} characters`
|
|
333
|
+
}).trim().min(1, {
|
|
334
|
+
error: `text must be text of 1 to ${LIMITS.textLength} characters`
|
|
335
|
+
}).max(LIMITS.textLength, {
|
|
336
|
+
error: `text must be text of 1 to ${LIMITS.textLength} characters`
|
|
337
|
+
});
|
|
338
|
+
var starsError = "must be from 1 to 5 in steps of 0.5";
|
|
339
|
+
function starBound(name) {
|
|
340
|
+
return z5.number({ error: `${name} ${starsError}` }).min(1, { error: `${name} ${starsError}` }).max(5, { error: `${name} ${starsError}` }).refine((value) => Number.isInteger(value * 2), {
|
|
341
|
+
error: `${name} ${starsError}`
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
var amenitiesError = `amenities must list 1 to ${LIMITS.amenityFilters} codes from: ${AMENITY_CODES.join(", ")}`;
|
|
345
|
+
var searchFilters = strictInput({
|
|
346
|
+
stars: strictInput({
|
|
347
|
+
min: starBound("stars.min").optional().describe("Lowest star rating, 1 to 5 in steps of 0.5."),
|
|
348
|
+
max: starBound("stars.max").optional().describe("Highest star rating, 1 to 5 in steps of 0.5.")
|
|
349
|
+
}).superRefine((value, ctx) => {
|
|
350
|
+
if (value.min !== void 0 && value.max !== void 0 && value.min > value.max) {
|
|
351
|
+
ctx.addIssue({
|
|
352
|
+
code: "custom",
|
|
353
|
+
path: ["max"],
|
|
354
|
+
message: "stars.max must be at least stars.min"
|
|
355
|
+
});
|
|
356
|
+
}
|
|
287
357
|
}).optional().describe(
|
|
288
|
-
"
|
|
358
|
+
"Star rating range. Unrated hotels are left out whenever it is set."
|
|
289
359
|
),
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
360
|
+
amenities: z5.array(oneOf("Each amenity", AMENITY_CODES), { error: amenitiesError }).min(1, { error: amenitiesError }).max(LIMITS.amenityFilters, { error: amenitiesError }).optional().describe(
|
|
361
|
+
"Amenities every hotel must list. Only some codes can filter; asking for one that cannot is answered with the list that can."
|
|
362
|
+
),
|
|
363
|
+
price: strictInput({
|
|
364
|
+
maxPerRoomNight: usdMoney.describe(
|
|
365
|
+
"Highest price per room per night, in USD."
|
|
366
|
+
)
|
|
367
|
+
}).optional().describe("Price limit. Needs indicative pricing, so a stay.")
|
|
368
|
+
}).optional().describe("Filters every hotel in the answer must pass.");
|
|
369
|
+
var NEXT_PAGE_KEYS = /* @__PURE__ */ new Set(["cursor", "limit"]);
|
|
370
|
+
function createSearchHotelsInput(now = Date.now) {
|
|
371
|
+
return strictInput({
|
|
372
|
+
destination: destination.optional().describe("Where to search. A first page needs this or `text`."),
|
|
373
|
+
text: textQuery.optional().describe(
|
|
374
|
+
`Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or \`destination\`.`
|
|
375
|
+
),
|
|
376
|
+
stay: stay.optional().describe(
|
|
377
|
+
"Dates and party. Needed for indicative prices. Search uses only the dates when the rooms differ."
|
|
378
|
+
),
|
|
379
|
+
pricing: oneOf("pricing", ["indicative", "none"]).optional().describe(
|
|
380
|
+
"`indicative` adds an approximate price per hotel and needs a stay; `none` skips prices. Default: indicative with a stay, otherwise none."
|
|
381
|
+
),
|
|
382
|
+
filters: searchFilters,
|
|
383
|
+
sort: oneOf("sort", SEARCH_SORTS).optional().describe(
|
|
384
|
+
"Result order. `distance` needs a destination, `relevance` needs `text`, `price_asc` and `price_desc` need indicative pricing. Default: relevance with `text`, otherwise distance."
|
|
385
|
+
),
|
|
386
|
+
limit: wholeNumber("limit", 1, LIMITS.pageSizeMax).optional().describe(
|
|
387
|
+
`Hotels per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
|
|
388
|
+
),
|
|
389
|
+
cursor: cursor.optional().describe(
|
|
390
|
+
"`nextCursor` from the previous response. A next page is the cursor and, optionally, `limit`; nothing else."
|
|
391
|
+
),
|
|
392
|
+
locale: locale.optional(),
|
|
393
|
+
displayCurrency: displayCurrency.optional()
|
|
394
|
+
}).superRefine((input, ctx) => {
|
|
395
|
+
const issue = (path, message) => ctx.addIssue({ code: "custom", path, message });
|
|
396
|
+
if (input.cursor !== void 0) {
|
|
397
|
+
for (const [key, value] of Object.entries(input)) {
|
|
398
|
+
if (value !== void 0 && !NEXT_PAGE_KEYS.has(key)) {
|
|
399
|
+
issue(
|
|
400
|
+
[key],
|
|
401
|
+
`${key} cannot be sent with cursor: a next page is the cursor and limit only`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (input.destination === void 0 && input.text === void 0) {
|
|
408
|
+
issue(["destination"], "Provide a destination or text");
|
|
409
|
+
}
|
|
410
|
+
if (input.sort === "distance" && input.destination === void 0) {
|
|
411
|
+
issue(["sort"], "sort distance needs a destination");
|
|
412
|
+
}
|
|
413
|
+
if (input.sort === "relevance" && input.text === void 0) {
|
|
414
|
+
issue(["sort"], "sort relevance needs text");
|
|
415
|
+
}
|
|
416
|
+
const indicative = (input.pricing ?? (input.stay ? "indicative" : "none")) === "indicative";
|
|
417
|
+
if (input.pricing === "indicative" && input.stay === void 0) {
|
|
418
|
+
issue(["stay"], "pricing indicative needs a stay");
|
|
419
|
+
}
|
|
420
|
+
if (!indicative) {
|
|
421
|
+
const reason = input.pricing === "none" ? 'pricing is "none"' : "there is no stay";
|
|
422
|
+
if (input.sort === "price_asc" || input.sort === "price_desc") {
|
|
423
|
+
issue(
|
|
424
|
+
["sort"],
|
|
425
|
+
`${input.sort} needs indicative pricing, but ${reason}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (input.filters?.price !== void 0) {
|
|
429
|
+
issue(
|
|
430
|
+
["filters", "price"],
|
|
431
|
+
`filters.price needs indicative pricing, but ${reason}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const checkIn = input.stay?.checkIn;
|
|
436
|
+
if (checkIn !== void 0 && isIsoDate(checkIn)) {
|
|
437
|
+
const earliest = new Date(now() - 12 * 36e5).toISOString().slice(0, 10);
|
|
438
|
+
if (checkIn < earliest) {
|
|
439
|
+
issue(["stay", "checkIn"], "stay.checkIn must not be in the past");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
var searchHotelsInput = createSearchHotelsInput();
|
|
445
|
+
var getHotelInput = strictInput({
|
|
446
|
+
hotelId: hotelId.describe("hotelId from a search response."),
|
|
447
|
+
include: oneOf("include", ["rooms"]).optional().describe("`rooms` adds the room list."),
|
|
448
|
+
locale: locale.optional()
|
|
449
|
+
});
|
|
450
|
+
var hotelIdsError = `hotelIds must list 1 to ${LIMITS.offersHotels} distinct hotelIds`;
|
|
451
|
+
var hotelOffersInput = strictInput({
|
|
452
|
+
hotelIds: z5.array(hotelId, { error: hotelIdsError }).min(1, { error: hotelIdsError }).max(LIMITS.offersHotels, { error: hotelIdsError }).refine((ids) => new Set(ids).size === ids.length, {
|
|
453
|
+
error: hotelIdsError
|
|
454
|
+
}).describe(`Hotels to price, 1 to ${LIMITS.offersHotels}, no repeats.`),
|
|
455
|
+
stay: stay.describe(
|
|
456
|
+
"Dates and party. One party per call: every room must be the same."
|
|
296
457
|
),
|
|
297
|
-
|
|
298
|
-
"Keep only
|
|
458
|
+
filters: strictInput({
|
|
459
|
+
requireBreakfast: flag("requireBreakfast").optional().describe("Keep only offers that include breakfast."),
|
|
460
|
+
requireFreeCancellation: flag("requireFreeCancellation").optional().describe("Keep only offers that can still be cancelled free of charge.")
|
|
461
|
+
}).optional().describe("Filters every offer must pass."),
|
|
462
|
+
limitPerHotel: wholeNumber("limitPerHotel", 1, LIMITS.offersPerHotelMax).default(LIMITS.offersPerHotelDefault).describe(
|
|
463
|
+
`Offers per hotel, 1 to ${LIMITS.offersPerHotelMax}. Default ${LIMITS.offersPerHotelDefault}.`
|
|
299
464
|
),
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
)
|
|
303
|
-
}).superRefine((input, ctx) => {
|
|
304
|
-
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
305
|
-
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
306
|
-
if (nights < 1) {
|
|
307
|
-
ctx.addIssue({
|
|
308
|
-
code: "custom",
|
|
309
|
-
path: ["checkOut"],
|
|
310
|
-
message: "checkOut must be after checkIn"
|
|
311
|
-
});
|
|
312
|
-
} else if (nights > LIMITS.nights) {
|
|
313
|
-
ctx.addIssue({
|
|
314
|
-
code: "custom",
|
|
315
|
-
path: ["checkOut"],
|
|
316
|
-
message: `rates support at most ${LIMITS.nights} nights`
|
|
317
|
-
});
|
|
318
|
-
}
|
|
465
|
+
locale: locale.optional(),
|
|
466
|
+
displayCurrency: displayCurrency.optional()
|
|
319
467
|
});
|
|
320
468
|
function cardFreeText(name, min, max, minDigits) {
|
|
321
469
|
return text(name, min, max).refine(
|
|
@@ -334,271 +482,322 @@ function guestName(name) {
|
|
|
334
482
|
{ error: GUEST_NAME_MESSAGE }
|
|
335
483
|
);
|
|
336
484
|
}
|
|
485
|
+
function contactName(name, max) {
|
|
486
|
+
return z5.string({ error: `${name} must be text of 1 to ${max} characters` }).refine((value) => !containsCardNumber(value), {
|
|
487
|
+
error: CARD_NUMBER_MESSAGE,
|
|
488
|
+
abort: true
|
|
489
|
+
}).refine(
|
|
490
|
+
(value) => {
|
|
491
|
+
const length = normalizeGuestName(value).length;
|
|
492
|
+
return length >= 1 && length <= max;
|
|
493
|
+
},
|
|
494
|
+
{ error: `${name} must be text of 1 to ${max} characters` }
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
var emailError = "email must be an email address";
|
|
337
498
|
var guestsError = "guests must list at least one guest per room";
|
|
338
|
-
var
|
|
339
|
-
var
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
"
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
guests: z2.array(
|
|
499
|
+
var maxGuestsPerRoom = LIMITS.adultsPerRoom + LIMITS.childrenPerRoom;
|
|
500
|
+
var createOrderInput = strictInput({
|
|
501
|
+
contact: strictInput({
|
|
502
|
+
firstName: contactName("firstName", LIMITS.contactFirstName).describe(
|
|
503
|
+
`Contact person's first name, at most ${LIMITS.contactFirstName} characters.`
|
|
504
|
+
),
|
|
505
|
+
lastName: contactName("lastName", LIMITS.contactLastName).describe(
|
|
506
|
+
`Contact person's last name, at most ${LIMITS.contactLastName} characters.`
|
|
507
|
+
),
|
|
508
|
+
email: z5.string({ error: emailError }).trim().pipe(z5.email({ error: emailError })).describe("Contact email; the confirmation is sent here."),
|
|
509
|
+
// Only cards of 16+ digits: a phone number has at most 15 (E.164).
|
|
510
|
+
phone: cardFreeText(
|
|
511
|
+
"phone",
|
|
512
|
+
LIMITS.contactPhoneMin,
|
|
513
|
+
LIMITS.contactPhoneMax,
|
|
514
|
+
16
|
|
515
|
+
).describe("Contact phone number, with country code.")
|
|
516
|
+
}).describe("Who the hotel and the confirmation reach."),
|
|
517
|
+
items: z5.array(
|
|
358
518
|
strictInput({
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
519
|
+
offerId: offerId.describe("offerId from an offers response."),
|
|
520
|
+
hotel: strictInput({
|
|
521
|
+
rooms: z5.array(
|
|
522
|
+
strictInput({
|
|
523
|
+
guests: z5.array(
|
|
524
|
+
strictInput({
|
|
525
|
+
firstName: guestName("firstName").describe(
|
|
526
|
+
"Guest's first name, in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
|
|
527
|
+
),
|
|
528
|
+
lastName: guestName("lastName").describe(
|
|
529
|
+
"Guest's last name, in Latin letters as on the passport."
|
|
530
|
+
)
|
|
531
|
+
}),
|
|
532
|
+
{ error: guestsError }
|
|
533
|
+
).min(1, { error: guestsError }).max(maxGuestsPerRoom, {
|
|
534
|
+
error: `guests must list at most ${maxGuestsPerRoom} guests per room`
|
|
535
|
+
}).describe(
|
|
536
|
+
"Guests staying in this room: at least one, no more than the room sleeps."
|
|
537
|
+
)
|
|
538
|
+
}),
|
|
539
|
+
{ error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
|
|
540
|
+
).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
|
|
541
|
+
error: `rooms must list 1 to ${LIMITS.rooms} rooms`
|
|
542
|
+
}).describe("One entry per room of the offer, in order."),
|
|
543
|
+
specialRequests: cardFreeText(
|
|
544
|
+
"specialRequests",
|
|
545
|
+
0,
|
|
546
|
+
LIMITS.specialRequests
|
|
547
|
+
).optional().describe("Requests for the hotel, in English. Not guaranteed.")
|
|
548
|
+
}).describe("What the hotel needs to know.")
|
|
363
549
|
}),
|
|
364
|
-
{ error:
|
|
365
|
-
).min(1, { error:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
550
|
+
{ error: "items must list exactly one offer" }
|
|
551
|
+
).min(1, { error: "items must list exactly one offer" }).max(1, { error: "items must list exactly one offer" }).describe("The offer to book. Exactly one in v1.")
|
|
552
|
+
});
|
|
553
|
+
var getOrderInput = strictInput({
|
|
554
|
+
orderId: orderId.describe("orderId from create order or list orders."),
|
|
555
|
+
locale: locale.optional()
|
|
556
|
+
});
|
|
557
|
+
var listLimitError = `limit must be a whole number from 1 to ${LIMITS.pageSizeMax}`;
|
|
558
|
+
var listOrdersInput = strictInput({
|
|
559
|
+
cursor: cursor.optional().describe("`nextCursor` from the previous page of orders."),
|
|
560
|
+
limit: z5.coerce.number({ error: listLimitError }).int({ error: listLimitError }).min(1, { error: listLimitError }).max(LIMITS.pageSizeMax, { error: listLimitError }).optional().describe(
|
|
561
|
+
`Orders per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
|
|
370
562
|
),
|
|
371
|
-
|
|
372
|
-
// its digits aren't mistaken for a card.
|
|
373
|
-
contactPhone: cardFreeText(
|
|
374
|
-
"contactPhone",
|
|
375
|
-
LIMITS.contactPhoneMin,
|
|
376
|
-
LIMITS.contactPhoneMax,
|
|
377
|
-
16
|
|
378
|
-
).describe("Contact phone number."),
|
|
379
|
-
// Blank means none, as it did before: the booking sends no email then.
|
|
380
|
-
contactEmail: z2.string({ error: contactEmailError }).trim().refine((value) => value === "" || z2.email().safeParse(value).success, {
|
|
381
|
-
error: contactEmailError
|
|
382
|
-
}).optional().describe("Contact email."),
|
|
383
|
-
specialRequests: cardFreeText("specialRequests", 0, LIMITS.specialRequests).optional().describe("Requests for the hotel, in English. Not guaranteed.")
|
|
384
|
-
}).superRefine((input, ctx) => {
|
|
385
|
-
if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
|
|
386
|
-
const nights = countIsoDateDays(input.checkIn, input.checkOut);
|
|
387
|
-
if (nights < 1) {
|
|
388
|
-
ctx.addIssue({
|
|
389
|
-
code: "custom",
|
|
390
|
-
path: ["checkOut"],
|
|
391
|
-
message: "checkOut must be after checkIn"
|
|
392
|
-
});
|
|
393
|
-
} else if (nights > LIMITS.nights) {
|
|
394
|
-
ctx.addIssue({
|
|
395
|
-
code: "custom",
|
|
396
|
-
path: ["checkOut"],
|
|
397
|
-
message: `A booking covers at most ${LIMITS.nights} nights; book a longer stay as separate bookings`
|
|
398
|
-
});
|
|
399
|
-
}
|
|
563
|
+
locale: locale.optional()
|
|
400
564
|
});
|
|
401
565
|
|
|
402
566
|
// src/commands.ts
|
|
403
|
-
function fieldDescription(json, field) {
|
|
567
|
+
function fieldDescription(json, field, sets = {}) {
|
|
404
568
|
let node = json;
|
|
569
|
+
const path = [];
|
|
405
570
|
for (const part of field.split(".")) {
|
|
406
|
-
|
|
571
|
+
if (node?.type === "array") node = node.items;
|
|
572
|
+
const at = path.join(".");
|
|
573
|
+
const variants = node?.oneOf ?? node?.anyOf ?? (node ? [node] : []);
|
|
574
|
+
node = variants.find(
|
|
575
|
+
(variant) => variant.properties?.[part] && Object.entries(sets).every(
|
|
576
|
+
([key, value]) => !key.startsWith(`${at}.`) || variant.properties?.[key.slice(at.length + 1)]?.const === value
|
|
577
|
+
)
|
|
578
|
+
)?.properties?.[part];
|
|
579
|
+
path.push(part);
|
|
407
580
|
}
|
|
408
581
|
if (!node?.description) throw new Error(`No description for field ${field}`);
|
|
409
582
|
return node.description;
|
|
410
583
|
}
|
|
411
584
|
function flagsOf(input, defs) {
|
|
412
|
-
const json =
|
|
585
|
+
const json = z6.toJSONSchema(input, { io: "input" });
|
|
413
586
|
return defs.map((def) => ({
|
|
414
587
|
...def,
|
|
415
|
-
description: def.description ?? fieldDescription(json, def.field)
|
|
588
|
+
description: def.description ?? fieldDescription(json, def.field, def.sets)
|
|
416
589
|
}));
|
|
417
590
|
}
|
|
418
|
-
|
|
591
|
+
function positional(input, name, description) {
|
|
592
|
+
const json = z6.toJSONSchema(input, { io: "input" });
|
|
593
|
+
return {
|
|
594
|
+
name,
|
|
595
|
+
field: name,
|
|
596
|
+
description: description ?? fieldDescription(json, name)
|
|
597
|
+
};
|
|
598
|
+
}
|
|
419
599
|
var COUNT = "<n>";
|
|
420
600
|
var DATE = "YYYY-MM-DD";
|
|
601
|
+
var ROOM = "<adults[:age,\u2026]>";
|
|
602
|
+
var LOCALES = "en|zh";
|
|
603
|
+
var kind = (value) => ({ "destination.kind": value });
|
|
604
|
+
var localeFlag = {
|
|
605
|
+
flag: "locale",
|
|
606
|
+
field: "locale",
|
|
607
|
+
type: "string",
|
|
608
|
+
placeholder: LOCALES
|
|
609
|
+
};
|
|
610
|
+
var displayCurrencyFlag = {
|
|
611
|
+
flag: "display-currency",
|
|
612
|
+
field: "displayCurrency",
|
|
613
|
+
type: "string",
|
|
614
|
+
placeholder: DISPLAY_CURRENCIES.join("|")
|
|
615
|
+
};
|
|
616
|
+
function stayFlags(roomDescription) {
|
|
617
|
+
return [
|
|
618
|
+
{ flag: "check-in", field: "stay.checkIn", type: "string", placeholder: DATE },
|
|
619
|
+
{ flag: "check-out", field: "stay.checkOut", type: "string", placeholder: DATE },
|
|
620
|
+
{
|
|
621
|
+
flag: "room",
|
|
622
|
+
field: "stay.rooms",
|
|
623
|
+
type: "string",
|
|
624
|
+
multiple: true,
|
|
625
|
+
placeholder: ROOM,
|
|
626
|
+
description: roomDescription
|
|
627
|
+
}
|
|
628
|
+
];
|
|
629
|
+
}
|
|
630
|
+
var ROOM_FORMAT = "as `adults` or `adults:age,age` with each child's age, e.g. `2` or `2:6,9`";
|
|
421
631
|
var searchFlags = flagsOf(searchHotelsInput, [
|
|
422
632
|
{
|
|
423
|
-
flag: "
|
|
424
|
-
|
|
425
|
-
field: "query",
|
|
633
|
+
flag: "place-name",
|
|
634
|
+
field: "destination.name",
|
|
426
635
|
type: "string",
|
|
427
|
-
|
|
428
|
-
|
|
636
|
+
sets: kind("place_name"),
|
|
637
|
+
placeholder: "<name>",
|
|
638
|
+
description: "Search a place by its name, in any language (e.g. Tokyo or \u4E1C\u4EAC), with its country in `--country`."
|
|
429
639
|
},
|
|
430
|
-
{ flag: "city-code", field: "cityCode", type: "id", placeholder: COUNT },
|
|
431
640
|
{
|
|
432
|
-
flag: "
|
|
433
|
-
field: "
|
|
641
|
+
flag: "country",
|
|
642
|
+
field: "destination.countryCode",
|
|
434
643
|
type: "string",
|
|
435
|
-
|
|
436
|
-
|
|
644
|
+
sets: kind("place_name"),
|
|
645
|
+
placeholder: "<CC>",
|
|
646
|
+
description: "The country of `--place-name`, ISO 3166-1 alpha-2, e.g. JP."
|
|
437
647
|
},
|
|
438
|
-
{ flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
|
|
439
|
-
{ flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
|
|
440
|
-
{ flag: "star-min", field: "starMin", type: "number", placeholder: COUNT },
|
|
441
|
-
{ flag: "star-max", field: "starMax", type: "number", placeholder: COUNT },
|
|
442
648
|
{
|
|
443
|
-
flag: "
|
|
444
|
-
field: "
|
|
649
|
+
flag: "place",
|
|
650
|
+
field: "destination.placeId",
|
|
445
651
|
type: "string",
|
|
446
|
-
|
|
447
|
-
placeholder: "<
|
|
652
|
+
sets: kind("place"),
|
|
653
|
+
placeholder: "<plc_>",
|
|
654
|
+
description: "Search a place by its placeId from an earlier answer."
|
|
448
655
|
},
|
|
449
|
-
{ flag: "price-min", field: "priceMin", type: "number", placeholder: COUNT },
|
|
450
|
-
{ flag: "price-max", field: "priceMax", type: "number", placeholder: COUNT },
|
|
451
656
|
{
|
|
452
|
-
flag: "
|
|
453
|
-
field: "
|
|
657
|
+
flag: "locality",
|
|
658
|
+
field: "destination.placeId",
|
|
454
659
|
type: "string",
|
|
455
|
-
|
|
660
|
+
sets: kind("locality"),
|
|
661
|
+
placeholder: "<plc_>",
|
|
662
|
+
description: "Search a locality by its placeId, as an earlier search listed it under localities."
|
|
456
663
|
},
|
|
457
|
-
{ flag: "page", field: "page", type: "number", placeholder: COUNT },
|
|
458
|
-
{ flag: "page-size", field: "pageSize", type: "number", placeholder: COUNT },
|
|
459
664
|
{
|
|
460
|
-
flag: "
|
|
461
|
-
field: "
|
|
665
|
+
flag: "point",
|
|
666
|
+
field: "destination.location",
|
|
462
667
|
type: "string",
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
var hotelFlags = flagsOf(getHotelInput, [
|
|
467
|
-
{ flag: "with-rooms", field: "includeRooms", type: "boolean" }
|
|
468
|
-
]);
|
|
469
|
-
var ratesFlags = flagsOf(getRatesInput, [
|
|
470
|
-
{
|
|
471
|
-
flag: "hotel",
|
|
472
|
-
field: "hotelIds",
|
|
473
|
-
type: "id",
|
|
474
|
-
multiple: true,
|
|
475
|
-
placeholder: ID,
|
|
476
|
-
description: `Hotels to price, by id from \`hotels search\`: repeat the flag or comma-separate, up to ${LIMITS.ratesHotels}. Each call takes about 15 seconds.`
|
|
668
|
+
sets: kind("point"),
|
|
669
|
+
placeholder: "<lat,lng>",
|
|
670
|
+
description: "Search around a point, as `lat,lng` (WGS84), e.g. `13.75,100.5`."
|
|
477
671
|
},
|
|
478
|
-
{ flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
|
|
479
|
-
{ flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
|
|
480
|
-
{ flag: "rooms", field: "rooms", type: "number", placeholder: COUNT },
|
|
481
672
|
{
|
|
482
|
-
flag: "
|
|
483
|
-
field: "
|
|
484
|
-
type: "
|
|
485
|
-
|
|
486
|
-
|
|
673
|
+
flag: "country-only",
|
|
674
|
+
field: "destination.countryCode",
|
|
675
|
+
type: "string",
|
|
676
|
+
sets: kind("country"),
|
|
677
|
+
placeholder: "<CC>",
|
|
678
|
+
description: "Search a whole country, ISO 3166-1 alpha-2, e.g. JP."
|
|
487
679
|
},
|
|
488
680
|
{
|
|
489
|
-
flag: "
|
|
490
|
-
field: "
|
|
681
|
+
flag: "radius-meters",
|
|
682
|
+
field: "destination.radiusMeters",
|
|
491
683
|
type: "number",
|
|
492
|
-
|
|
493
|
-
placeholder: "<age>"
|
|
684
|
+
placeholder: COUNT
|
|
494
685
|
},
|
|
495
686
|
{
|
|
496
|
-
flag: "
|
|
497
|
-
field: "
|
|
687
|
+
flag: "text",
|
|
688
|
+
field: "text",
|
|
498
689
|
type: "string",
|
|
499
|
-
placeholder: "<
|
|
690
|
+
placeholder: "<text>",
|
|
691
|
+
description: `Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or a destination.`
|
|
500
692
|
},
|
|
693
|
+
...stayFlags(
|
|
694
|
+
`A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; rooms may differ.`
|
|
695
|
+
),
|
|
501
696
|
{
|
|
502
|
-
flag: "
|
|
503
|
-
field: "
|
|
697
|
+
flag: "pricing",
|
|
698
|
+
field: "pricing",
|
|
504
699
|
type: "string",
|
|
505
|
-
|
|
506
|
-
placeholder: BOARD_CODES.join("|")
|
|
507
|
-
},
|
|
508
|
-
{ flag: "free-cancellation", field: "freeCancellation", type: "boolean" },
|
|
509
|
-
{
|
|
510
|
-
flag: "include-unbookable",
|
|
511
|
-
field: "bookableOnly",
|
|
512
|
-
type: "boolean",
|
|
513
|
-
description: "Also keep rates that are not bookable on every night (by default only bookable rates are returned)."
|
|
700
|
+
placeholder: "indicative|none"
|
|
514
701
|
},
|
|
702
|
+
{ flag: "stars-min", field: "filters.stars.min", type: "number", placeholder: COUNT },
|
|
703
|
+
{ flag: "stars-max", field: "filters.stars.max", type: "number", placeholder: COUNT },
|
|
515
704
|
{
|
|
516
|
-
flag: "
|
|
517
|
-
field: "
|
|
518
|
-
type: "
|
|
519
|
-
|
|
520
|
-
|
|
705
|
+
flag: "amenity",
|
|
706
|
+
field: "filters.amenities",
|
|
707
|
+
type: "string",
|
|
708
|
+
multiple: true,
|
|
709
|
+
placeholder: FILTERABLE_AMENITIES.join("|"),
|
|
710
|
+
description: `An amenity every hotel must list; repeat for each, up to ${LIMITS.amenityFilters}.`
|
|
521
711
|
},
|
|
522
712
|
{
|
|
523
|
-
flag: "
|
|
524
|
-
field: "
|
|
713
|
+
flag: "price-max-per-room-night",
|
|
714
|
+
field: "filters.price.maxPerRoomNight",
|
|
525
715
|
type: "string",
|
|
526
|
-
placeholder:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
var bookFlags = flagsOf(createBookingInput, [
|
|
530
|
-
{ flag: "hotel", field: "hotelId", type: "id", placeholder: ID },
|
|
716
|
+
placeholder: "<USD decimal>",
|
|
717
|
+
description: "Highest indicative price per room per night, in USD, e.g. `150` or `149.50`. Needs a stay."
|
|
718
|
+
},
|
|
531
719
|
{
|
|
532
|
-
flag: "
|
|
533
|
-
field: "
|
|
720
|
+
flag: "sort",
|
|
721
|
+
field: "sort",
|
|
534
722
|
type: "string",
|
|
535
|
-
placeholder:
|
|
536
|
-
description: "`roomId` from `hotels rates`"
|
|
723
|
+
placeholder: SEARCH_SORTS.join("|")
|
|
537
724
|
},
|
|
725
|
+
{ flag: "limit", field: "limit", type: "number", placeholder: COUNT },
|
|
538
726
|
{
|
|
539
|
-
flag: "
|
|
540
|
-
field: "
|
|
727
|
+
flag: "cursor",
|
|
728
|
+
field: "cursor",
|
|
541
729
|
type: "string",
|
|
542
|
-
placeholder: "<
|
|
543
|
-
description: "`
|
|
730
|
+
placeholder: "<cursor>",
|
|
731
|
+
description: "`nextCursor` from the previous page. A next page takes only `--cursor` and `--limit`."
|
|
544
732
|
},
|
|
545
|
-
|
|
546
|
-
|
|
733
|
+
localeFlag,
|
|
734
|
+
displayCurrencyFlag
|
|
735
|
+
]);
|
|
736
|
+
var hotelFlags = flagsOf(getHotelInput, [
|
|
547
737
|
{
|
|
548
738
|
flag: "rooms",
|
|
549
|
-
field: "
|
|
550
|
-
type: "
|
|
551
|
-
|
|
552
|
-
description: "
|
|
553
|
-
},
|
|
554
|
-
{
|
|
555
|
-
flag: "adults",
|
|
556
|
-
field: "adults",
|
|
557
|
-
type: "number",
|
|
558
|
-
placeholder: COUNT,
|
|
559
|
-
description: "Adults per room, not in total, as in `hotels rates` (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
|
|
560
|
-
},
|
|
561
|
-
{
|
|
562
|
-
flag: "child-age",
|
|
563
|
-
field: "childrenAges",
|
|
564
|
-
type: "number",
|
|
565
|
-
multiple: true,
|
|
566
|
-
placeholder: "<age>",
|
|
567
|
-
description: "Age of each child in one room (0-17), per room, as in `hotels rates`"
|
|
739
|
+
field: "include",
|
|
740
|
+
type: "boolean",
|
|
741
|
+
value: "rooms",
|
|
742
|
+
description: "Add the hotel's rooms."
|
|
568
743
|
},
|
|
744
|
+
localeFlag
|
|
745
|
+
]);
|
|
746
|
+
var offersFlags = flagsOf(hotelOffersInput, [
|
|
569
747
|
{
|
|
570
|
-
flag: "
|
|
571
|
-
field: "
|
|
748
|
+
flag: "hotel",
|
|
749
|
+
field: "hotelIds",
|
|
572
750
|
type: "string",
|
|
573
751
|
multiple: true,
|
|
574
|
-
placeholder:
|
|
575
|
-
description:
|
|
752
|
+
placeholder: "<htl_>",
|
|
753
|
+
description: `A hotel to price, by hotelId from \`hotels search\`; repeat for each, up to ${LIMITS.offersHotels}.`
|
|
576
754
|
},
|
|
755
|
+
...stayFlags(
|
|
756
|
+
`A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; every room must be the same.`
|
|
757
|
+
),
|
|
758
|
+
{ flag: "require-breakfast", field: "filters.requireBreakfast", type: "boolean" },
|
|
577
759
|
{
|
|
578
|
-
flag: "
|
|
579
|
-
field: "
|
|
580
|
-
type: "
|
|
581
|
-
placeholder: "<name>"
|
|
760
|
+
flag: "require-free-cancellation",
|
|
761
|
+
field: "filters.requireFreeCancellation",
|
|
762
|
+
type: "boolean"
|
|
582
763
|
},
|
|
764
|
+
{ flag: "limit-per-hotel", field: "limitPerHotel", type: "number", placeholder: COUNT },
|
|
765
|
+
localeFlag,
|
|
766
|
+
displayCurrencyFlag
|
|
767
|
+
]);
|
|
768
|
+
var bookFlags = flagsOf(createOrderInput, [
|
|
583
769
|
{
|
|
584
|
-
flag: "
|
|
585
|
-
field: "
|
|
770
|
+
flag: "offer",
|
|
771
|
+
field: "items.offerId",
|
|
586
772
|
type: "string",
|
|
587
|
-
placeholder: "<
|
|
773
|
+
placeholder: "<off_>",
|
|
774
|
+
description: "The offer to order, by offerId from `hotels offers`."
|
|
588
775
|
},
|
|
589
776
|
{
|
|
590
|
-
flag: "
|
|
591
|
-
field: "
|
|
777
|
+
flag: "guest",
|
|
778
|
+
field: "items.hotel.rooms.guests",
|
|
592
779
|
type: "string",
|
|
593
|
-
|
|
780
|
+
multiple: true,
|
|
781
|
+
placeholder: "<room>:<first>:<last>",
|
|
782
|
+
description: "A guest, as `room:first:last`, e.g. `1:John:Smith` for room 1. Repeat for each guest: at least one per room of the offer, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
|
|
594
783
|
},
|
|
784
|
+
{ flag: "first-name", field: "contact.firstName", type: "string", placeholder: "<name>" },
|
|
785
|
+
{ flag: "last-name", field: "contact.lastName", type: "string", placeholder: "<name>" },
|
|
786
|
+
{ flag: "email", field: "contact.email", type: "string", placeholder: "<email>" },
|
|
787
|
+
{ flag: "phone", field: "contact.phone", type: "string", placeholder: "<phone>" },
|
|
595
788
|
{
|
|
596
789
|
flag: "special-requests",
|
|
597
|
-
field: "specialRequests",
|
|
790
|
+
field: "items.hotel.specialRequests",
|
|
598
791
|
type: "string",
|
|
599
792
|
placeholder: "<english text>"
|
|
600
793
|
}
|
|
601
794
|
]);
|
|
795
|
+
var orderFlags = flagsOf(getOrderInput, [localeFlag]);
|
|
796
|
+
var ordersListFlags = flagsOf(listOrdersInput, [
|
|
797
|
+
{ flag: "cursor", field: "cursor", type: "string", placeholder: "<cursor>" },
|
|
798
|
+
{ flag: "limit", field: "limit", type: "number", placeholder: COUNT },
|
|
799
|
+
localeFlag
|
|
800
|
+
]);
|
|
602
801
|
var BODY_OPTION = {
|
|
603
802
|
flag: "body",
|
|
604
803
|
type: "string",
|
|
@@ -606,13 +805,14 @@ var BODY_OPTION = {
|
|
|
606
805
|
description: "The raw request body as a JSON object, or `@file.json` to read it from a file. Flags override its fields."
|
|
607
806
|
};
|
|
608
807
|
var COMMAND_GROUPS = [
|
|
609
|
-
{ name: "hotels", summary: "Search,
|
|
808
|
+
{ name: "hotels", summary: "Search hotels, get offers and order one" },
|
|
809
|
+
{ name: "orders", summary: "Your orders and their status" }
|
|
610
810
|
];
|
|
611
811
|
var COMMANDS = [
|
|
612
812
|
{
|
|
613
813
|
group: "hotels",
|
|
614
814
|
name: "search",
|
|
615
|
-
summary: "Find hotels with indicative prices",
|
|
815
|
+
summary: "Find hotels in a place, near a point or by name, with indicative prices",
|
|
616
816
|
tool: "search_hotels",
|
|
617
817
|
flags: searchFlags,
|
|
618
818
|
options: [BODY_OPTION]
|
|
@@ -620,27 +820,42 @@ var COMMANDS = [
|
|
|
620
820
|
{
|
|
621
821
|
group: "hotels",
|
|
622
822
|
name: "get",
|
|
623
|
-
summary: "
|
|
823
|
+
summary: "Hotel details: description, policies, amenities and rooms",
|
|
624
824
|
tool: "get_hotel",
|
|
625
825
|
flags: hotelFlags,
|
|
626
|
-
positionals: ["hotelId"]
|
|
826
|
+
positionals: [positional(getHotelInput, "hotelId")]
|
|
627
827
|
},
|
|
628
828
|
{
|
|
629
829
|
group: "hotels",
|
|
630
|
-
name: "
|
|
631
|
-
summary: `Live
|
|
632
|
-
tool: "
|
|
633
|
-
flags:
|
|
830
|
+
name: "offers",
|
|
831
|
+
summary: `Live bookable offers for up to ${LIMITS.offersHotels} hotels`,
|
|
832
|
+
tool: "get_hotel_offers",
|
|
833
|
+
flags: offersFlags,
|
|
634
834
|
options: [BODY_OPTION]
|
|
635
835
|
},
|
|
636
836
|
{
|
|
637
837
|
group: "hotels",
|
|
638
838
|
name: "book",
|
|
639
|
-
summary: "
|
|
839
|
+
summary: "Order an offer: an unpaid order with a payment link",
|
|
640
840
|
tool: "book_hotel",
|
|
641
841
|
flags: bookFlags,
|
|
642
842
|
options: [BODY_OPTION]
|
|
643
843
|
},
|
|
844
|
+
{
|
|
845
|
+
group: "orders",
|
|
846
|
+
name: "get",
|
|
847
|
+
summary: "Show an order and its status",
|
|
848
|
+
tool: "get_order",
|
|
849
|
+
flags: orderFlags,
|
|
850
|
+
positionals: [positional(getOrderInput, "orderId")]
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
group: "orders",
|
|
854
|
+
name: "list",
|
|
855
|
+
summary: "List your orders, newest first",
|
|
856
|
+
tool: "list_orders",
|
|
857
|
+
flags: ordersListFlags
|
|
858
|
+
},
|
|
644
859
|
{
|
|
645
860
|
group: null,
|
|
646
861
|
name: "login",
|
|
@@ -675,7 +890,14 @@ var COMMANDS = [
|
|
|
675
890
|
summary: "Print the guide for AI agents (Markdown)",
|
|
676
891
|
tool: "get_planning_guide",
|
|
677
892
|
flags: [],
|
|
678
|
-
positionals: [
|
|
893
|
+
positionals: [
|
|
894
|
+
{
|
|
895
|
+
name: "topic",
|
|
896
|
+
field: null,
|
|
897
|
+
optional: true,
|
|
898
|
+
description: "`planning` for the planning guide; left out, the main guide."
|
|
899
|
+
}
|
|
900
|
+
]
|
|
679
901
|
},
|
|
680
902
|
{
|
|
681
903
|
group: null,
|
|
@@ -700,22 +922,139 @@ var GLOBAL_FLAGS = [
|
|
|
700
922
|
short: "v",
|
|
701
923
|
type: "boolean",
|
|
702
924
|
description: "Print the CLI version."
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
flag: "json",
|
|
928
|
+
type: "boolean",
|
|
929
|
+
description: "Print the answer as its JSON on one line instead of text; an error prints its JSON envelope, on stderr."
|
|
703
930
|
}
|
|
704
931
|
];
|
|
705
932
|
|
|
933
|
+
// src/conventions/open.ts
|
|
934
|
+
import { z as z7 } from "zod";
|
|
935
|
+
var OPEN = /* @__PURE__ */ Symbol("fortrip.open");
|
|
936
|
+
function mark(schema, kind2) {
|
|
937
|
+
schema._zod.def[OPEN] = kind2;
|
|
938
|
+
}
|
|
939
|
+
function openObject(shape) {
|
|
940
|
+
return z7.looseObject(shape);
|
|
941
|
+
}
|
|
942
|
+
function openEnum(values) {
|
|
943
|
+
const schema = z7.union([
|
|
944
|
+
z7.enum(values),
|
|
945
|
+
z7.string()
|
|
946
|
+
]);
|
|
947
|
+
mark(schema, "enum");
|
|
948
|
+
return schema;
|
|
949
|
+
}
|
|
950
|
+
function openUnion(discriminator, branches) {
|
|
951
|
+
const known = /* @__PURE__ */ new Set();
|
|
952
|
+
for (const branch of branches) {
|
|
953
|
+
const field = branch.shape[discriminator];
|
|
954
|
+
const values = field?._zod.values;
|
|
955
|
+
if (!values) {
|
|
956
|
+
throw new Error(`openUnion: branch has no literal "${discriminator}"`);
|
|
957
|
+
}
|
|
958
|
+
for (const v of values) known.add(v);
|
|
959
|
+
}
|
|
960
|
+
const fallback = z7.looseObject({
|
|
961
|
+
[discriminator]: z7.string().refine((v) => !known.has(v), { error: "malformed value for a known type" })
|
|
962
|
+
});
|
|
963
|
+
const schema = z7.union([...branches, fallback]);
|
|
964
|
+
mark(schema, "union");
|
|
965
|
+
return schema;
|
|
966
|
+
}
|
|
967
|
+
function parseOpen(schema, value) {
|
|
968
|
+
return schema.parse(value);
|
|
969
|
+
}
|
|
970
|
+
function strictOutput(schema) {
|
|
971
|
+
return strictify(schema);
|
|
972
|
+
}
|
|
973
|
+
var LEAVES = /* @__PURE__ */ new Set([
|
|
974
|
+
"string",
|
|
975
|
+
"number",
|
|
976
|
+
"boolean",
|
|
977
|
+
"bigint",
|
|
978
|
+
"null",
|
|
979
|
+
"undefined",
|
|
980
|
+
"literal",
|
|
981
|
+
"enum",
|
|
982
|
+
"date",
|
|
983
|
+
"nan",
|
|
984
|
+
"never",
|
|
985
|
+
"template_literal",
|
|
986
|
+
"transform"
|
|
987
|
+
]);
|
|
988
|
+
var WRAPPERS = {
|
|
989
|
+
optional: "innerType",
|
|
990
|
+
nullable: "innerType",
|
|
991
|
+
default: "innerType",
|
|
992
|
+
prefault: "innerType",
|
|
993
|
+
nonoptional: "innerType",
|
|
994
|
+
readonly: "innerType",
|
|
995
|
+
array: "element"
|
|
996
|
+
};
|
|
997
|
+
function strictify(schema) {
|
|
998
|
+
const def = schema._zod.def;
|
|
999
|
+
const kind2 = def[OPEN];
|
|
1000
|
+
const rebuild = (changes) => {
|
|
1001
|
+
const next = { ...def, ...changes };
|
|
1002
|
+
delete next[OPEN];
|
|
1003
|
+
return schema.clone(next);
|
|
1004
|
+
};
|
|
1005
|
+
if (kind2 === "enum") return strictify(def.options[0]);
|
|
1006
|
+
if (kind2 === "union") {
|
|
1007
|
+
return rebuild({ options: def.options.slice(0, -1).map(strictify) });
|
|
1008
|
+
}
|
|
1009
|
+
const inner = WRAPPERS[def.type];
|
|
1010
|
+
if (inner) return rebuild({ [inner]: strictify(def[inner]) });
|
|
1011
|
+
switch (def.type) {
|
|
1012
|
+
case "object": {
|
|
1013
|
+
const shape = def.shape;
|
|
1014
|
+
return rebuild({
|
|
1015
|
+
shape: Object.fromEntries(
|
|
1016
|
+
Object.entries(shape).map(([k, v]) => [k, strictify(v)])
|
|
1017
|
+
),
|
|
1018
|
+
catchall: z7.never()
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
case "union":
|
|
1022
|
+
return rebuild({ options: def.options.map(strictify) });
|
|
1023
|
+
case "tuple":
|
|
1024
|
+
return rebuild({
|
|
1025
|
+
items: def.items.map(strictify),
|
|
1026
|
+
rest: def.rest ? strictify(def.rest) : def.rest
|
|
1027
|
+
});
|
|
1028
|
+
case "record":
|
|
1029
|
+
return rebuild({ valueType: strictify(def.valueType) });
|
|
1030
|
+
case "pipe":
|
|
1031
|
+
return rebuild({
|
|
1032
|
+
in: strictify(def.in),
|
|
1033
|
+
out: strictify(def.out)
|
|
1034
|
+
});
|
|
1035
|
+
case "lazy": {
|
|
1036
|
+
const getter = def.getter;
|
|
1037
|
+
return rebuild({ getter: () => strictify(getter()) });
|
|
1038
|
+
}
|
|
1039
|
+
default:
|
|
1040
|
+
if (LEAVES.has(def.type)) return schema;
|
|
1041
|
+
throw new Error(`strictOutput: cannot close a "${def.type}" schema`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
706
1045
|
// src/guide-data.generated.ts
|
|
707
1046
|
var FRAGMENTS = {
|
|
708
|
-
"booking":
|
|
709
|
-
"errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `
|
|
710
|
-
"meals":
|
|
711
|
-
"occupancy": "{{#if claude-skill cli}}\n##
|
|
712
|
-
"order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels
|
|
713
|
-
"payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels book}}`
|
|
714
|
-
"planning": "# Planning a hotel stay\n\nRead this when the request is more than one hotel for fixed dates: flexible dates, a budget, split stays, several destinations, or \"find me the best\". The rules in {{#if mcp}}the server instructions{{/if}}{{#if claude-skill}}SKILL.md{{/if}}{{#if cli}}`{{tool:guide}}`{{/if}} still apply (call order,
|
|
715
|
-
"prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n
|
|
716
|
-
"scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live
|
|
717
|
-
"setup": "{{#if cli}}\n## Setup\n\nRun every command as `npx -y fortrip@latest <command>` (Node.js 22 or newer). The examples below shorten that to `fortrip`; `fortrip <command> --help` lists a command's flags. `{{tool:hotels search}}`, `{{tool:hotels
|
|
718
|
-
"support": "{{#if claude-skill cli}}\n## Changes and support\n\n{{/if}}\n{{#if mcp claude-skill}}These tools{{/if}}{{#if cli}}The CLI{{/if}} can't change or cancel
|
|
1047
|
+
"booking": "{{#if claude-skill cli}}\n## Ordering\n\n{{/if}}\n- Call `{{tool:hotels book}}` only for the offer the user picked, with at least one guest per room{{#if claude-skill cli}}, named as the user gave them{{/if}}; never invent guests.{{#if claude-skill cli}} Room 1 is the offer's first room, and a room takes no more guests than it sleeps.{{/if}}\n- Guest names: Latin letters as on the passport (M\xFCller \u2192 Mueller), first and last name 1\u2013{{limit:guestName}} characters each{{#if cli}}, as `--guest 1:John:Smith` (room 1, first name, last name){{/if}}.\n{{#if claude-skill cli}}\n- The contact's first and last name (up to {{limit:contactFirstName}} characters each), email and phone (with its country code) are all required. Special requests must be in English and aren't guaranteed.\n{{/if}}\n- `{{tool:hotels book}}` can take up to about two minutes{{#if cli}}: allow the command at least 3 minutes{{/if}}. If it times out, {{#if cli}}run it again with identical flags{{/if}}{{#if mcp claude-skill}}call it again with the same details{{/if}}: it returns the same order.\n{{#if mcp}}\n- On `offer_changed`, show the user every change and ask again before ordering the new offer.\n{{/if}}\n{{#if cli}}\n- `{{tool:hotels book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n",
|
|
1048
|
+
"errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `code`, `message` and `retryable`, and `retryAfterSeconds` when waiting helps; many carry `details`. Search errors are under Search. Ordering errors:\n\n| `code` | Do this |\n|---|---|\n| `offer_changed` (409) | The price or terms changed since the offer: `changes` lists every change (`worse` says which are worse for the user) and `offer` is the new offer. Show the user every change and ask again; if they still want it, order the new offer, and confirm the new order before giving its link |\n| `offer_expired` (409) | Get offers again and let the user choose again |\n| `offer_unavailable` (422) | The offer is sold out or was refused: get offers again and let the user choose again |\n| `order_in_progress` (409) | The same order is still being created: wait `retryAfterSeconds`, then {{#if claude-skill}}call again with the same details{{/if}}{{#if cli}}run the command again with identical flags{{/if}} |\n| `offer_already_ordered` (409) | This offer was already ordered with other details: show that order (`orderId`) instead. An offer is ordered once |\n| `order_exists` (409) | This stay already has an order (`orderId`, `status`): show it instead of ordering again. This also blocks ordering the same offer, dates and rooms again for another party, ordering a stay again after it was cancelled, ordering while the user's own website checkout for the stay is open (up to 3 hours 45 minutes) and ordering again a stay whose earlier order failed at the hotel. For those, the user emails {{support}} |\n| `order_attempts_exhausted` (409) | This order can't be tried again: the user emails {{support}} with its `reference` |\n| `order_removed` (410) | The order was never completed and no payment is held: get offers again and order the new offer (the order keeps its `orderId`) |\n| `stay_unsupported` (422) | `mixed_occupancy`: order once per party; `max_nights`: at most `max` nights per order |\n| `stay_in_past` (422) | Check-in has passed in the hotel's time zone: ask for new dates |\n| `ordering_paused` (503) | Ordering is paused on Fortrip's side: tell the user, and try again only after `retryAfterSeconds` (10 minutes or more) |\n\nAny code:\n\n- `invalid_request` (400): `details.issues` lists each bad field; fix them. For amenities, `details.filterable` lists those that can filter.\n- `cursor_invalid` (400): the page is gone; search again from the first page.\n- `not_found` (404): no such hotel, or no such order for this user.\n- `rate_limited` (429): wait `retryAfterSeconds`, then retry once.\n- `service_unavailable` (503) and `supplier_unavailable` (503 or 504): wait `retryAfterSeconds`, then retry once with the same details; if it fails again, tell the user.\n- Any other code: read `message`. If `retryable` is true, wait `retryAfterSeconds` and retry once; otherwise tell the user instead of guessing.\n{{/if}}\n",
|
|
1049
|
+
"meals": '{{#if claude-skill cli}}\n## Presenting an offer\n\nGive the user, for each offer:\n\n- the room (`room.name`, beds, size) and the total, as above;\n- the meals: `meals.included` (breakfast, lunch, dinner) or `allInclusive`, with `guestsCoveredPerRoom`. Fewer than the guests per room means not everyone gets the meals (breakfast for 1 with 2 adults in the room): say who is covered. `unspecifiedMainMeals` counts main meals the hotel didn\'t name. With `known: false`, the meals are unknown: say so, and don\'t call it room only;\n- cancellation: `status` (`free`, `partial_penalty`, `non_refundable` or `unknown`), `freeCancellationEndsAt` (null with `free`: free until check-in) and the `penalties`. Times are in UTC: also give them in the hotel\'s `timeZone`;\n- confirmation: `mode` `instant` or `on_request` (within `withinMinutes`);\n- fees payable at the property (`payAtProperty.items`), in the property\'s currency. `disclosure: "incomplete"` means there can be fees not listed: say so.\n\nAn offer expires at `expiresAt`, about 30 minutes after it was made; after that, get offers again.\n\n"Not listed" is not "no": `listed: false` (amenities, policies, rooms), `null` and `unknown` mean the source didn\'t say. Tell the user it\'s unknown instead of turning it into "no".\n{{/if}}\n{{#if mcp}}\n- "Not listed" or "unknown" is not "no".\n{{/if}}\n',
|
|
1050
|
+
"occupancy": "{{#if claude-skill cli}}\n## Rooms and guests\n\n- {{#if mcp claude-skill}}`stay.rooms` lists each room's party as `{ adults, childrenAges }`: two rooms of 2 adults, one with a child of 6, is `[{ adults: 2, childrenAges: [6] }, { adults: 2, childrenAges: [] }]`{{/if}}{{#if cli}}`--room <adults[:age,\u2026]>`, once per room, gives each room's party: two rooms of 2 adults, one with a child of 6, is `--room 2:6 --room 2`{{/if}}.\n- Ask each child's age; offers need it.\n- If the party doesn't split evenly across the rooms (3 adults in 2 rooms), ask the user how it splits.\n{{/if}}\n- Offers and orders take one party per call, every room the same: for different parties, order once per party.{{#if claude-skill cli}} `{{tool:hotels offers}}` answers `stay_unsupported` (`mixed_occupancy`) for mixed rooms; `{{tool:hotels search}}` accepts them but prices only from the dates, with the note `party_split_required`.{{/if}}\n{{#if claude-skill cli}}\n- Limits: 1\u2013{{limit:rooms}} rooms, 1\u2013{{limit:adultsPerRoom}} adults and 0\u2013{{limit:childrenPerRoom}} children (aged 0\u2013{{limit:childAgeMax}}) per room, 1\u2013{{limit:nightsSupplier}} nights, up to {{limit:offersHotels}} hotels per `{{tool:hotels offers}}` call. A longer stay answers `stay_unsupported` (`max_nights`): ask the user whether to split it into consecutive stays, each its own order.\n{{/if}}\n",
|
|
1051
|
+
"order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels: a destination, `{{arg:text}}` or both, and the stay\'s dates and rooms for indicative prices.\n{{#if cli}}\n\n ```bash\n {{tool:hotels search}} --place-name Bangkok --country TH --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --stars-min 4 --sort price_asc --limit 10\n ```\n\n{{/if}}\n2. **Details** (optional) with `{{tool:hotels get}}`, when the user asks about a hotel or a stated preference needs evidence (a pool, a policy, room size). For a details-only question, call it directly when you know the hotel\'s `hotelId`; otherwise find the hotel with `{{tool:hotels search}}`.\n{{#if cli}}\n\n ```bash\n {{tool:hotels get}} <hotelId> [--rooms]\n ```\n\n{{/if}}\n3. **Offers** with `{{tool:hotels offers}}` for a few shortlisted hotels, with the dates and rooms the user will order. It takes about 15 seconds. A hotel is available only once this returns an offer for it.\n{{#if cli}}\n\n ```bash\n {{tool:hotels offers}} --hotel <hotelId> --hotel <hotelId> --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --room 2 [--require-free-cancellation]\n ```\n\n{{/if}}\n4. **Order** with `{{tool:hotels book}}`, once the user has picked one offer and given the guests and the contact (see Ordering).\n{{#if cli}}\n\n ```bash\n {{tool:hotels book}} --offer <offerId> --guest 1:John:Smith --guest 2:Mary:Smith \\\n --first-name John --last-name Smith --email john@example.com \\\n --phone "+8613800000000"\n ```\n\n{{/if}}\n5. **Confirm, then give the payment link**, as in "Before giving the payment link" below.\n6. **After payment**, check the order with `{{tool:orders get}}` (see After payment).\n\n## Search\n\n### Where\n\n- **A place by name:** {{#if mcp claude-skill}}`{ kind: "place_name", name, countryCode }`{{/if}}{{#if cli}}`--place-name <name> --country <CC>`{{/if}}, in any language (Kyoto or \u4EAC\u90FD). The country is required: ask the user when it isn\'t clear. Names match exactly, ignoring only case and spacing: accents, hyphens, apostrophes and periods count ("K\xF6ln" or "Cologne", not "Koln"; "St. Moritz", not "St Moritz").\n- If several places bear the name, the answer is `destination_ambiguous` with up to {{limit:candidatesShown}} candidates and their hotel counts: ask the user which one they mean, then search it by its `placeId`; never pick one yourself.\n- `destination_not_found` with `no_match` means no place of that name in that country: check the spelling and accents, or try another name for the place. Its `nameAsText` counts the hotels that a `{{arg:text}}` search for the name would find.\n- **A place from an earlier answer:** {{#if mcp claude-skill}}`{ kind: "place", placeId }`{{/if}}{{#if cli}}`--place <placeId>`{{/if}}.\n- **Near a point:** {{#if mcp claude-skill}}`{ kind: "point", location: { lat, lng } }`{{/if}}{{#if cli}}`--point <lat,lng>`{{/if}}, for "near X" when you know where X is.\n- **A whole country:** {{#if mcp claude-skill}}`{ kind: "country", countryCode }`{{/if}}{{#if cli}}`--country-only <CC>`{{/if}}, as for the Maldives.\n- Countries, regions (states, provinces), prefecture-level cities in China and main islands cover their whole area. Towns, neighbourhoods, districts, small islands and points cover a circle of {{limit:radiusMetersDefault}} meters; `{{arg:destination.radiusMeters}}` sets another, up to {{limit:radiusMetersMax}} meters, and narrows an area to a circle around its center.\n- **Localities:** an area search lists its `localities`, the towns its hotels belong to, each with a `placeId`, its hotel `count` and the places it `includes` (Patong is part of Phuket). To search one, pass it as {{#if mcp claude-skill}}`{ kind: "locality", placeId }`{{/if}}{{#if cli}}`--locality <placeId>`{{/if}}. A town that isn\'t a locality answers `destination_not_found` with `not_a_locality` and the `hub` it belongs to: search the hub, or the town itself with {{#if mcp claude-skill}}`kind: "place"`{{/if}}{{#if cli}}`--place`{{/if}} for a circle around it.\n- A price sort or price filter over more than {{limit:poolLimit}} hotels (in an area, or for text alone) answers `search_too_broad` with `matched` and the top `localities`: show them and ask the user which they prefer, unless the request already names one of them; search that one by its `placeId` as a locality, never by its name.\n\n### Text\n\n`{{arg:text}}` matches exact words in hotel names and addresses, in Chinese or English:\n\n- Every word must match as written: plurals, accents, spacing and abbreviations count ("hostel" and "hostels", "St Regis" and "Saint Regis" find different hotels). Hyphens and apostrophes don\'t (Ritz-Carlton finds Ritz Carlton).\n- In Chinese, matching follows word segmentation, so part of a name can match unrelated hotels (\u5E0C\u5C14 is not \u5E0C\u5C14\u987F).\n- Use only distinctive words: a hotel or brand name, a district or a landmark. Not "hotel", "near", "cheap" or "best".\n- Typos return nothing: "Shangahi" finds no hotel.\n- Place names belong in the destination: "Maldives" as text finds a hotel named Maldives in China.\n\n### Filters\n\n- Stars run from 1 to 5 in half steps{{#if mcp claude-skill}} (`filters.stars`){{/if}}. "4 stars" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. Unrated hotels are left out whenever stars are filtered.\n- `{{arg:filters.amenities}}` takes only the amenities that can filter (such as pool, parking, wifi); others, such as gym or spa, show in `{{tool:hotels get}}` but can\'t filter. A hotel matches only when it lists the amenity, and about half the hotels list no amenities at all (`coverage.excludedNoFacilityData` counts those left out): "not listed" is not "absent".\n- `{{arg:filters.price.maxPerRoomNight}}` bounds the indicative price per room per night, in USD; hotels without one are left out and counted.\n\n### Results\n\n- `notes` explain the answer (a limited pool, a circle around an island, more hotels just outside it): pass on what matters to the user.\n- `coverage` has the counts: `matched` hotels, how many were priced and how many have a price.\n- A next page is {{#if mcp claude-skill}}`cursor` set to `nextCursor` and, optionally, `limit`{{/if}}{{#if cli}}`--cursor <nextCursor>` and, optionally, `--limit`{{/if}}; nothing else.\n{{/if}}\n{{#if mcp}}\n- A place name needs its `countryCode` (ask the user if unclear) and matches exactly (accents and periods count). On `destination_ambiguous`, ask the user which they mean, then search its `placeId`; never pick one yourself.\n- On `search_too_broad`, show its `localities` and ask the user which, unless they already named one; search it as `{ kind: "locality", placeId }`, never by name.\n- `text` matches whole words of hotel names and addresses: use a distinctive name, brand or landmark. A typo finds nothing.\n{{/if}}\n',
|
|
1052
|
+
"payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels book}}` answers with an unpaid order: `status` `awaiting_payment`, its `reference` (the support number), `price.total`, the stay and its terms, and `payment.url`, a Fortrip page that recaps the order and continues to payment on Stripe. Nothing is charged until the user pays.\n\n1. Show the order's total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term: meals, cancellation (status, deadline, penalties) and fees payable at the property, unknowns included.\n2. End that reply by asking the user to confirm, and wait for a clear yes. Urgency is not consent.\n3. Then give `payment.url` exactly as returned: don't shorten, rebuild or re-encode it.\n\n{{/if}}\n{{#if mcp}}\n- Before you give `payment.url`, show the user the order's total and currency, the stay and every term, end that reply by asking the user to confirm, and wait for a clear yes. Then give `payment.url` exactly as returned, to this user only: anyone holding it can pay.\n{{/if}}\n{{#if claude-skill cli}}\n- `payment.url` works like a payment credential: anyone holding it can pay. Give it to this user only, never in shared channels, documents, logs or tickets. With `requiresSignIn: true` (an order started on the Fortrip website), the user signs in to pay.\n- The link expires at `payment.expiresAt`; the order is then `expired`. To order the stay again, get offers again, and confirm the new order with the user before giving its link.\n- An order `awaiting_payment` whose `payment.url` is null had its link unreadable this time: read it again with `{{tool:orders get}}`.\n{{/if}}\n- Never ask for, accept or pass on payment card details; payment happens only on the payment page.{{#if claude-skill cli}} A card number in a guest name, the contact or the special requests is rejected{{#if cli}} with exit 2{{/if}}.{{/if}}\n{{#if claude-skill cli}}\n\n## After payment\n\nCheck the order with `{{tool:orders get}}`:\n\n- `confirming` while the hotel confirms, then `confirmed` with the hotel's `confirmationNumber`.\n- `failed`: no stay was booked; `payment.status` says whether the payment was released or refunded.\n- `needs_attention`: Fortrip is looking into it; the user can email {{support}} with the `reference`.\n- `manageUrl` is the user's own page for the order.\n{{/if}}\n",
|
|
1053
|
+
"planning": "# Planning a hotel stay\n\nRead this when the request is more than one hotel for fixed dates: flexible dates, a budget, split stays, several destinations, or \"find me the best\". The rules in {{#if mcp}}the server instructions{{/if}}{{#if claude-skill}}SKILL.md{{/if}}{{#if cli}}`{{tool:guide}}`{{/if}} still apply (call order, destinations, one party per offer and order, confirming the order `{{tool:hotels book}}` returns before giving the payment link); this covers what to ask, how to compare and how to present.{{#if cli}} As there, `fortrip` is short for `npx -y fortrip@latest`.{{/if}}\n\n## What to collect\n\nKeep track of: dates (or the flexible window), nights, destination(s) with their country, rooms and the guests in each room, room constraints (beds, connecting rooms, accessibility), budget, and what the user said matters most (cheapest, most flexible, closest to X).\n\nOffers and orders take one party per call, every room the same, so if the party doesn't split evenly across the rooms, ask how it splits. Rooms for different parties are priced and ordered separately, each order with its own payment link.\n\nAsk only for what changes which hotels qualify or how they rank. \"4 stars\" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. If children come along, ask each child's age; `{{tool:hotels offers}}` needs it. Don't ask for trip type or style: infer it from the conversation or leave it general. When a place name is ambiguous, ask which place the user means.\n\n## Evidence\n\n- A `{{tool:hotels search}}` price is an indicative estimate per room per night, not a quote. Never call a search result available or bookable, and once you have offers, compare offers only. A hotel without a search price can still have offers.\n- Use `{{tool:hotels get}}` only when a stated preference needs evidence (a pool, a policy, room size). An amenity or policy it doesn't list is unknown, not absent.\n- A hotel is available only once `{{tool:hotels offers}}` returned an offer for it.\n- Anything {{#if mcp claude-skill}}the tools{{/if}}{{#if cli}}the CLI{{/if}} didn't return, such as reviews, style, price trends or exchange rates other than `approx`, is unknown. Say so instead of filling it in.\n\n## Ranking\n\n1. Drop options that break a hard constraint (dates, guests, budget cap, required facility). Compare a budget in the unit the user gave it: per night, per room or for the whole stay. An offer's `price.total` covers the whole stay and every room, in USD.\n2. Keep only options with a live offer.\n3. Order by the user's stated main goal. Goals they didn't state don't count.\n4. Use other preferences only to break ties.\n\nUse the cheapest comparable offer as the baseline unless the user fixed the room type. Show at most three plans, each with its price difference from the baseline and what it trades off (meals, cancellation, location, confirmation). Keep hotel names as returned and dates in the user's notation.\n\nFor a split stay or several destinations, price each leg with its own `{{tool:hotels offers}}` call and add up the legs. Each leg is a separate `{{tool:hotels book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:hotels offers}}` covers up to {{limit:offersHotels}} hotels per call and takes about 15 seconds, and its offers expire after about 30 minutes. If the request needs more hotels or date combinations than a few calls can cover, narrow it with the user, or present the result as a comparison of the hotels you priced, not the best overall.\n- When a search is too broad to price, show its localities and ask the user which they prefer, unless the request already names one of them; never pick one yourself.\n- Urgency is not consent. Call `{{tool:hotels book}}` only for the offer the user picked, and give its payment link only after the user confirms the total, currency, stay and terms of the order `{{tool:hotels book}}` returned.\n",
|
|
1054
|
+
"prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n- A search price (`price.fromPerRoomNight`) is indicative and not bookable: the lowest price of one unit (a room or, at a hostel, possibly a bed) per night, for any party and often any date, and it can be well above or below the offers. Call it an estimate, quote `{{tool:hotels offers}}` prices, and never call a search result available or bookable. `price: null` means there is no indicative price, not that the hotel is full; about half the hotels have none for given dates.\n{{/if}}\n{{#if mcp}}\n- Search prices are indicative, not bookable: quote `{{tool:hotels offers}}` prices.\n{{/if}}\n- {{#if claude-skill cli}}In an offer or order, {{/if}}`price.total` is the whole stay for all rooms and is what is charged, in USD for offers and the orders made from them.{{#if claude-skill cli}} `perNightAllRooms` is the total per night; to compare with a per-room budget, also divide by the rooms.{{/if}} Stripe's page may let the user pay in their card's local currency at Stripe's rate (Adaptive Pricing){{#if claude-skill cli}}: say so plainly, and that the USD total is the price{{/if}}. `approx`{{#if claude-skill cli}}, in `{{arg:displayCurrency}}`, with its `rateDate`,{{/if}} is an estimate, never the charge.\n{{#if claude-skill cli}}\n- `{{arg:displayCurrency}}`{{#if cli}} (or `FORTRIP_DISPLAY_CURRENCY`){{/if}} is `USD` or `CNY`: `CNY` adds `approx` in yuan to suit a user who thinks in it. It never changes the charge.\n- Fees payable at the property (`payAtProperty`) are separate, in the property's own currency, not converted.\n- After payment, `payment.localCharge` is what the card paid in its own currency. Orders made on the Fortrip website can be charged in another currency: `payment.chargeCurrency` says which.\n{{/if}}\n",
|
|
1055
|
+
"scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live offers and orders with a payment link.{{#if claude-skill cli}} Not for flights, activities, or changing or cancelling an existing order.{{/if}}\n\n{{#if mcp}}\nCall order: `{{tool:hotels search}}` \u2192 (optional) `{{tool:hotels get}}` \u2192 `{{tool:hotels offers}}` \u2192 `{{tool:hotels book}}`; after the user pays, `{{tool:orders get}}`; `{{tool:orders list}}` lists their orders. `{{tool:guide}}` returns Fortrip's guidance for flexible dates, budgets or several destinations.\n{{/if}}\n{{#if claude-skill}}\nThe tools are on Fortrip's MCP server, named `fortrip` in this plugin (a Fortrip connector added on claude.ai can show under its own name). They're written `fortrip:<tool>` below:\n{{/if}}\n{{#if cli}}\nThe `fortrip` CLI (npm package `fortrip`) calls Fortrip as the signed-in user and prints each answer as compact text (`--json` prints its JSON):\n{{/if}}\n{{#if claude-skill cli}}\n\n| {{#if claude-skill}}Tool{{/if}}{{#if cli}}Command{{/if}} | Returns |\n|---|---|\n| `{{tool:hotels search}}` | Hotels in a place, near a point or matching a name (`{{arg:text}}`), with indicative prices |\n| `{{tool:hotels get}}` | One hotel's address, check-in and check-out times, description, policies, amenities and rooms; no prices |\n| `{{tool:hotels offers}}` | Live offers (room, meals, total in USD, cancellation terms) for up to {{limit:offersHotels}} hotels |\n| `{{tool:hotels book}}` | An unpaid order for one offer, with its terms and a payment link |\n| `{{tool:orders get}}` | One order and its status, including whether the hotel confirmed it |\n| `{{tool:orders list}}` | The user's orders, newest first, including those made on the Fortrip website |\n\nFor flexible dates, a budget, split stays, several destinations or \"find me the best\", {{#if claude-skill}}read [references/planning.md](references/planning.md){{/if}}{{#if cli}}also run `{{tool:guide}} planning`{{/if}} before searching.\n{{/if}}\n",
|
|
1056
|
+
"setup": "{{#if cli}}\n## Setup\n\nRun every command as `npx -y fortrip@latest <command>` (Node.js 22 or newer). The examples below shorten that to `fortrip`; `fortrip <command> --help` lists a command's flags. `{{tool:hotels search}}`, `{{tool:hotels offers}}` and `{{tool:hotels book}}` also take `--body '<json>'` (or `--body @file.json`), the raw request body; flags override its fields. Add `--json` to any command that calls Fortrip to get its answer as one line of JSON instead of text.\n\nWhen the user first asks about hotels, run `{{tool:whoami}}` before asking follow-up questions, so the sign-in can happen while they answer.\n\n{{/if}}\n{{#if claude-skill cli}}\n## Sign-in\n\n{{/if}}\nSign-in happens only in the browser. Never ask the user to paste a token.\n{{#if claude-skill}}\n\nIf none of these tools are available, Fortrip isn't connected: tell the user to add the Fortrip connector or plugin and sign in.\n{{/if}}\n{{#if mcp claude-skill}}\nIf a tool says the user isn't signed in, ask them to reconnect Fortrip.\n{{/if}}\n{{#if cli}}\n\nIf a command exits with code **3** and stderr has `loginUrl`, show it to the user as a link together with `userCode`, e.g. \"Sign in to Fortrip: <loginUrl> (code <userCode>), then tell me when you're done.\" When they confirm, rerun the same command; it finishes the sign-in itself. Don't run `{{tool:login}}`: it waits for up to 10 minutes. Commands that exit 3 together share one `loginUrl`; show it once. If the rerun gives a different `loginUrl`, show that one.\n\nWhen Fortrip no longer accepts the saved sign-in (it was revoked, or it expired and couldn't be renewed), the command also exits 3 with a `loginUrl`; handle it the same way. The saved sign-in is kept until the user approves the new link.\n\nIn CI or a sandbox without a browser, the user can set `FORTRIP_TOKEN` instead. Exit 3 with `tokenSource: \"FORTRIP_TOKEN\"` means Fortrip didn't accept that token: tell the user to replace or unset it.\n\n## Exit codes\n\n| Code | Meaning | What to do |\n|---|---|---|\n| 0 | Success; the answer on stdout | Continue |\n| 1 | Any other error: an error from Fortrip (see Errors), a network error or a timeout | Read the error on stderr. If `retryable` is true, wait `retryAfterSeconds` (or a few seconds) and retry once |\n| 2 | Invalid arguments, or `invalid_request` from Fortrip | Fix the flags; the error lists each bad field with a message |\n| 3 | Not signed in | Show `loginUrl` to the user, then rerun (above) |\n| 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |\n\nOutput on stdout or stderr can carry `warning` (with `recoveryFile` when a sign-in couldn't be saved): pass it on to the user.\n{{/if}}\n",
|
|
1057
|
+
"support": "{{#if claude-skill cli}}\n## Changes and support\n\n{{/if}}\n{{#if mcp claude-skill}}These tools{{/if}}{{#if cli}}The CLI{{/if}} can't change or cancel an order: for changes, cancellations, refunds or other problems, the user emails {{support}} with the order's `reference` (never `payment.url`).\n"
|
|
719
1058
|
};
|
|
720
1059
|
|
|
721
1060
|
// src/guide.ts
|
|
@@ -783,13 +1122,13 @@ function select(text2, surface) {
|
|
|
783
1122
|
}
|
|
784
1123
|
continue;
|
|
785
1124
|
}
|
|
786
|
-
const
|
|
787
|
-
for (const name of
|
|
1125
|
+
const names2 = tag[1].trim().split(/\s+/);
|
|
1126
|
+
for (const name of names2) {
|
|
788
1127
|
if (!SURFACES.includes(name)) {
|
|
789
1128
|
throw new Error(`Unknown guide surface "${name}" in ${tag[0]}`);
|
|
790
1129
|
}
|
|
791
1130
|
}
|
|
792
|
-
open.push(
|
|
1131
|
+
open.push(names2.includes(surface));
|
|
793
1132
|
}
|
|
794
1133
|
if (open.length > 0) throw new Error("Guide has {{#if}} without {{/if}}");
|
|
795
1134
|
return out + tagged.slice(from);
|
|
@@ -797,18 +1136,18 @@ function select(text2, surface) {
|
|
|
797
1136
|
function resolve(token, surface, shown) {
|
|
798
1137
|
const body = token.slice(2, -2);
|
|
799
1138
|
if (body === "support") return SUPPORT_EMAIL;
|
|
800
|
-
const [
|
|
1139
|
+
const [kind2, name, ...rest] = body.split(":");
|
|
801
1140
|
if (name !== void 0 && rest.length === 0) {
|
|
802
|
-
if (
|
|
1141
|
+
if (kind2 === "limit" && Object.hasOwn(LIMITS, name)) {
|
|
803
1142
|
return String(LIMITS[name]);
|
|
804
1143
|
}
|
|
805
|
-
if (
|
|
1144
|
+
if (kind2 === "tool") {
|
|
806
1145
|
const spec = COMMANDS.find(
|
|
807
1146
|
(command) => commandInvocation(command) === `fortrip ${name}`
|
|
808
1147
|
);
|
|
809
1148
|
if (spec) return shown ? toolName(spec, surface, token) : token;
|
|
810
1149
|
}
|
|
811
|
-
if (
|
|
1150
|
+
if (kind2 === "arg") {
|
|
812
1151
|
const flags = new Set(
|
|
813
1152
|
COMMANDS.flatMap((command) => command.flags).filter((flag3) => flag3.field === name).map((flag3) => flag3.flag)
|
|
814
1153
|
);
|
|
@@ -828,170 +1167,1514 @@ function toolName(spec, surface, token) {
|
|
|
828
1167
|
return surface === "mcp" ? spec.tool : `fortrip:${spec.tool}`;
|
|
829
1168
|
}
|
|
830
1169
|
|
|
831
|
-
// src/outputs.ts
|
|
832
|
-
import { z as
|
|
833
|
-
var
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1170
|
+
// src/outputs/account.ts
|
|
1171
|
+
import { z as z8 } from "zod";
|
|
1172
|
+
var meResult = openObject({
|
|
1173
|
+
userId: z8.string(),
|
|
1174
|
+
email: z8.string().nullable(),
|
|
1175
|
+
name: z8.string().nullable()
|
|
1176
|
+
});
|
|
1177
|
+
var authConfig = openObject({
|
|
1178
|
+
issuer: z8.string(),
|
|
1179
|
+
clientId: z8.string(),
|
|
1180
|
+
scopes: z8.string(),
|
|
1181
|
+
deviceAuthorizationEndpoint: z8.string(),
|
|
1182
|
+
tokenEndpoint: z8.string(),
|
|
1183
|
+
revocationEndpoint: z8.string()
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
// src/outputs/common.ts
|
|
1187
|
+
import { z as z9 } from "zod";
|
|
1188
|
+
var names = openObject({
|
|
1189
|
+
en: z9.string().nullable(),
|
|
1190
|
+
zh: z9.string().nullable()
|
|
1191
|
+
});
|
|
1192
|
+
var latLng = openObject({ lat: z9.number(), lng: z9.number() });
|
|
1193
|
+
var LOCALES2 = ["en", "zh"];
|
|
1194
|
+
var knownLocaleText = openObject({
|
|
1195
|
+
text: z9.string(),
|
|
1196
|
+
locale: openEnum(LOCALES2)
|
|
1197
|
+
});
|
|
1198
|
+
var localizedText = openObject({
|
|
1199
|
+
text: z9.string(),
|
|
1200
|
+
locale: openEnum(LOCALES2).nullable()
|
|
1201
|
+
});
|
|
1202
|
+
var stayEcho = openObject({
|
|
1203
|
+
checkIn: localDate,
|
|
1204
|
+
checkOut: localDate,
|
|
1205
|
+
rooms: z9.array(
|
|
1206
|
+
openObject({
|
|
1207
|
+
adults: z9.number().int(),
|
|
1208
|
+
childrenAges: z9.array(z9.number().int())
|
|
1209
|
+
})
|
|
1210
|
+
).min(1)
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
// src/outputs/error-details.ts
|
|
1214
|
+
import { z as z15 } from "zod";
|
|
1215
|
+
|
|
1216
|
+
// src/errors.ts
|
|
1217
|
+
var ERROR_CODES = {
|
|
1218
|
+
invalid_request: { status: [400], retryable: false },
|
|
1219
|
+
cursor_invalid: { status: [400], retryable: false },
|
|
1220
|
+
unauthenticated: { status: [401], retryable: false },
|
|
1221
|
+
forbidden: { status: [403], retryable: false },
|
|
1222
|
+
not_found: { status: [404], retryable: false },
|
|
1223
|
+
order_removed: { status: [410], retryable: false },
|
|
1224
|
+
offer_expired: { status: [409], retryable: false },
|
|
1225
|
+
offer_changed: { status: [409], retryable: false },
|
|
1226
|
+
offer_already_ordered: { status: [409], retryable: false },
|
|
1227
|
+
order_exists: { status: [409], retryable: false },
|
|
1228
|
+
order_in_progress: { status: [409], retryable: true },
|
|
1229
|
+
order_attempts_exhausted: { status: [409], retryable: false },
|
|
1230
|
+
destination_ambiguous: { status: [422], retryable: false },
|
|
1231
|
+
destination_not_found: { status: [422], retryable: false },
|
|
1232
|
+
search_too_broad: { status: [422], retryable: false },
|
|
1233
|
+
stay_unsupported: { status: [422], retryable: false },
|
|
1234
|
+
stay_in_past: { status: [422], retryable: false },
|
|
1235
|
+
offer_unavailable: { status: [422], retryable: false },
|
|
1236
|
+
rate_limited: { status: [429], retryable: true },
|
|
1237
|
+
ordering_paused: { status: [503], retryable: true },
|
|
1238
|
+
service_unavailable: { status: [503], retryable: true },
|
|
1239
|
+
supplier_unavailable: { status: [503, 504], retryable: true },
|
|
1240
|
+
internal: { status: [500], retryable: false }
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
// src/outputs/offer.ts
|
|
1244
|
+
import { z as z13 } from "zod";
|
|
1245
|
+
|
|
1246
|
+
// src/outputs/hotel.ts
|
|
1247
|
+
import { z as z12 } from "zod";
|
|
1248
|
+
|
|
1249
|
+
// src/outputs/search.ts
|
|
1250
|
+
import { z as z11 } from "zod";
|
|
1251
|
+
|
|
1252
|
+
// src/outputs/place.ts
|
|
1253
|
+
import { z as z10 } from "zod";
|
|
1254
|
+
var PLACE_KINDS = [
|
|
1255
|
+
"country",
|
|
1256
|
+
"region",
|
|
1257
|
+
"district",
|
|
1258
|
+
"settlement",
|
|
1259
|
+
"neighbourhood",
|
|
1260
|
+
"island"
|
|
1261
|
+
];
|
|
1262
|
+
var placeRef = openObject({
|
|
1263
|
+
placeId,
|
|
1264
|
+
name: z10.string(),
|
|
1265
|
+
names
|
|
1266
|
+
});
|
|
1267
|
+
var place = openObject({
|
|
1268
|
+
placeId,
|
|
1269
|
+
name: z10.string(),
|
|
1270
|
+
names,
|
|
1271
|
+
kind: openEnum(PLACE_KINDS),
|
|
1272
|
+
scope: openEnum(["area", "radius"]),
|
|
1273
|
+
cityLike: z10.boolean().nullable(),
|
|
1274
|
+
population: z10.number().int().nullable(),
|
|
1275
|
+
region: placeRef.nullable(),
|
|
1276
|
+
countryCode: z10.string(),
|
|
1277
|
+
location: latLng
|
|
1278
|
+
});
|
|
1279
|
+
var CENTER_BASES = [
|
|
1280
|
+
"seat",
|
|
1281
|
+
"capital",
|
|
1282
|
+
"busiest_settlement",
|
|
1283
|
+
"hub",
|
|
1284
|
+
"point"
|
|
1285
|
+
];
|
|
1286
|
+
var center = openObject({
|
|
1287
|
+
location: latLng,
|
|
1288
|
+
place: placeRef.nullable(),
|
|
1289
|
+
basis: openEnum(CENTER_BASES)
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// src/outputs/search.ts
|
|
1293
|
+
var SEARCH_NOTE_CODES = [
|
|
1294
|
+
"indicative_prices",
|
|
1295
|
+
"prices_unavailable",
|
|
1296
|
+
"pool_limited",
|
|
1297
|
+
"amenities_listed_only",
|
|
1298
|
+
"place_resolved_dominant",
|
|
1299
|
+
"area_absorbed",
|
|
1300
|
+
"broad_area",
|
|
1301
|
+
"island_radius",
|
|
1302
|
+
"area_retired",
|
|
1303
|
+
"more_outside_radius",
|
|
1304
|
+
"more_elsewhere",
|
|
1305
|
+
"few_hotels_in_scope",
|
|
1306
|
+
"party_split_required",
|
|
1307
|
+
"no_results",
|
|
1308
|
+
"locality_available"
|
|
1309
|
+
];
|
|
1310
|
+
var indicativePrice = openObject({
|
|
1311
|
+
kind: openEnum(["indicative"]),
|
|
1312
|
+
basis: openEnum(["lowest_unit"]),
|
|
1313
|
+
fromPerRoomNight: money,
|
|
1314
|
+
fromPerRoomStay: money,
|
|
1315
|
+
approx: openObject({
|
|
1316
|
+
fromPerRoomNight: money,
|
|
1317
|
+
fromPerRoomStay: money,
|
|
1318
|
+
rateDate: localDate
|
|
1319
|
+
}).nullable()
|
|
1320
|
+
});
|
|
1321
|
+
var hotelSummary = openObject({
|
|
1322
|
+
hotelId,
|
|
1323
|
+
name: z11.string(),
|
|
1324
|
+
names,
|
|
1325
|
+
stars: z11.number().nullable(),
|
|
1326
|
+
address: openObject({
|
|
1327
|
+
line: knownLocaleText.nullable(),
|
|
1328
|
+
countryCode: z11.string().nullable(),
|
|
1329
|
+
region: placeRef.nullable()
|
|
1330
|
+
}),
|
|
1331
|
+
location: latLng.nullable(),
|
|
1332
|
+
timeZone: z11.string().nullable(),
|
|
1333
|
+
distanceMeters: z11.number().nullable(),
|
|
1334
|
+
price: indicativePrice.nullable()
|
|
1335
|
+
});
|
|
1336
|
+
var localities = openObject({
|
|
1337
|
+
items: z11.array(
|
|
1338
|
+
openObject({
|
|
1339
|
+
place: placeRef,
|
|
1340
|
+
count: z11.number().int(),
|
|
1341
|
+
total: z11.number().int(),
|
|
1342
|
+
includes: z11.array(
|
|
1343
|
+
openObject({ place: placeRef, members: z11.number().int() })
|
|
1344
|
+
)
|
|
1345
|
+
})
|
|
837
1346
|
),
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1347
|
+
unassigned: z11.number().int(),
|
|
1348
|
+
more: z11.number().int()
|
|
1349
|
+
});
|
|
1350
|
+
var radiusSource = openEnum(["default", "requested"]);
|
|
1351
|
+
var destination2 = openUnion("kind", [
|
|
1352
|
+
openObject({
|
|
1353
|
+
kind: z11.literal("place"),
|
|
1354
|
+
place,
|
|
1355
|
+
resolution: openEnum([
|
|
1356
|
+
"only_match",
|
|
1357
|
+
"dominant",
|
|
1358
|
+
"by_id",
|
|
1359
|
+
"by_country_code",
|
|
1360
|
+
"by_locality"
|
|
1361
|
+
]),
|
|
1362
|
+
otherMatches: z11.number().int(),
|
|
1363
|
+
scope: openEnum(["area", "radius", "locality"]),
|
|
1364
|
+
center,
|
|
1365
|
+
radiusMeters: z11.number().nullable(),
|
|
1366
|
+
radiusSource: radiusSource.nullable()
|
|
1367
|
+
}),
|
|
1368
|
+
openObject({
|
|
1369
|
+
kind: z11.literal("point"),
|
|
1370
|
+
center,
|
|
1371
|
+
scope: openEnum(["radius"]),
|
|
1372
|
+
radiusMeters: z11.number(),
|
|
1373
|
+
radiusSource
|
|
1374
|
+
})
|
|
1375
|
+
]);
|
|
1376
|
+
var count = z11.number().int();
|
|
1377
|
+
var searchResult = openObject({
|
|
1378
|
+
destination: destination2.nullable(),
|
|
1379
|
+
text: openObject({
|
|
1380
|
+
value: z11.string(),
|
|
1381
|
+
match: openEnum(["exact"])
|
|
1382
|
+
}).nullable(),
|
|
1383
|
+
stay: stayEcho.nullable(),
|
|
1384
|
+
pricing: openObject({
|
|
1385
|
+
mode: openEnum(["indicative", "none"]),
|
|
1386
|
+
scope: openEnum(["page", "pool"]).nullable(),
|
|
1387
|
+
status: openEnum(["ok", "unavailable"]).nullable()
|
|
1388
|
+
}),
|
|
1389
|
+
sort: openEnum([
|
|
1390
|
+
"distance",
|
|
1391
|
+
"relevance",
|
|
1392
|
+
"stars_desc",
|
|
1393
|
+
"price_asc",
|
|
1394
|
+
"price_desc"
|
|
1395
|
+
]),
|
|
1396
|
+
coverage: openObject({
|
|
1397
|
+
matched: count,
|
|
1398
|
+
priced: count.nullable(),
|
|
1399
|
+
withPrice: count.nullable(),
|
|
1400
|
+
poolRadiusMeters: z11.number().nullable(),
|
|
1401
|
+
excludedNoPrice: count.nullable(),
|
|
1402
|
+
excludedNoFacilityData: count.nullable(),
|
|
1403
|
+
withinMaxRadius: count.nullable(),
|
|
1404
|
+
textAnywhere: count.nullable(),
|
|
1405
|
+
localities: localities.nullable(),
|
|
1406
|
+
nameAsText: count.nullable()
|
|
1407
|
+
}),
|
|
1408
|
+
data: z11.array(hotelSummary),
|
|
1409
|
+
nextCursor: z11.string().nullable(),
|
|
1410
|
+
notes: z11.array(
|
|
1411
|
+
openObject({ code: openEnum(SEARCH_NOTE_CODES), message: z11.string() })
|
|
1412
|
+
)
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// src/outputs/hotel.ts
|
|
1416
|
+
var POLICY_TOPICS = [
|
|
1417
|
+
"check_in",
|
|
1418
|
+
"important_notices",
|
|
1419
|
+
"booking",
|
|
1420
|
+
"booking_tips",
|
|
1421
|
+
"check_in_out",
|
|
1422
|
+
"check_in_tips",
|
|
1423
|
+
"reception",
|
|
1424
|
+
"deposit",
|
|
1425
|
+
"meal",
|
|
1426
|
+
"extra_bed",
|
|
1427
|
+
"pets",
|
|
1428
|
+
"surroundings",
|
|
1429
|
+
"meetings",
|
|
1430
|
+
"non_smoking",
|
|
1431
|
+
"other"
|
|
1432
|
+
];
|
|
1433
|
+
var roomFacts = {
|
|
1434
|
+
beds: localizedText.nullable(),
|
|
1435
|
+
areaSqm: openObject({ min: z12.number(), max: z12.number() }).nullable(),
|
|
1436
|
+
maxOccupancy: z12.number().int().nullable(),
|
|
1437
|
+
window: openEnum(["yes", "no", "partial"]).nullable()
|
|
1438
|
+
};
|
|
1439
|
+
var timeRange = openObject({
|
|
1440
|
+
from: localTime.nullable(),
|
|
1441
|
+
until: localTime.nullable()
|
|
1442
|
+
});
|
|
1443
|
+
var hotel = openObject({
|
|
1444
|
+
hotelId,
|
|
1445
|
+
name: z12.string(),
|
|
1446
|
+
names,
|
|
1447
|
+
bookable: z12.boolean(),
|
|
1448
|
+
stars: z12.number().nullable(),
|
|
1449
|
+
address: hotelSummary.shape.address,
|
|
1450
|
+
location: latLng.nullable(),
|
|
1451
|
+
timeZone: z12.string().nullable(),
|
|
1452
|
+
description: knownLocaleText.nullable(),
|
|
1453
|
+
checkInTimes: timeRange,
|
|
1454
|
+
checkOutTimes: timeRange,
|
|
1455
|
+
policies: openObject({
|
|
1456
|
+
listed: z12.boolean(),
|
|
1457
|
+
items: z12.array(
|
|
1458
|
+
openObject({
|
|
1459
|
+
topic: openEnum(POLICY_TOPICS),
|
|
1460
|
+
text: z12.string(),
|
|
1461
|
+
locale: openEnum(["en", "zh"]).nullable()
|
|
867
1462
|
})
|
|
868
1463
|
)
|
|
869
|
-
})
|
|
1464
|
+
}),
|
|
1465
|
+
amenities: openObject({
|
|
1466
|
+
listed: z12.boolean(),
|
|
1467
|
+
items: z12.array(
|
|
1468
|
+
openObject({ code: openEnum(AMENITY_CODES), name: knownLocaleText })
|
|
1469
|
+
)
|
|
1470
|
+
}),
|
|
1471
|
+
breakfastServed: z12.boolean().nullable(),
|
|
1472
|
+
deposit: openObject({
|
|
1473
|
+
required: z12.boolean().nullable(),
|
|
1474
|
+
amount: money.nullable()
|
|
1475
|
+
}),
|
|
1476
|
+
contact: openObject({
|
|
1477
|
+
phone: z12.string().nullable(),
|
|
1478
|
+
website: z12.string().nullable()
|
|
1479
|
+
}),
|
|
1480
|
+
rooms: openObject({
|
|
1481
|
+
listed: z12.boolean(),
|
|
1482
|
+
items: z12.array(openObject({ name: localizedText, ...roomFacts }))
|
|
1483
|
+
}).optional()
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
// src/outputs/offer.ts
|
|
1487
|
+
var meals = openObject({
|
|
1488
|
+
known: z13.boolean(),
|
|
1489
|
+
included: z13.array(openEnum(["breakfast", "lunch", "dinner"])).nullable(),
|
|
1490
|
+
unspecifiedMainMeals: z13.number().int().nullable(),
|
|
1491
|
+
allInclusive: z13.boolean().nullable(),
|
|
1492
|
+
guestsCoveredPerRoom: z13.number().int().nullable(),
|
|
1493
|
+
label: localizedText.nullable()
|
|
1494
|
+
});
|
|
1495
|
+
var payAtProperty = openObject({
|
|
1496
|
+
disclosure: openEnum(["complete", "incomplete"]),
|
|
1497
|
+
items: z13.array(
|
|
1498
|
+
openObject({
|
|
1499
|
+
label: localizedText.nullable(),
|
|
1500
|
+
amount: money.nullable(),
|
|
1501
|
+
mandatory: z13.boolean(),
|
|
1502
|
+
basis: openEnum([
|
|
1503
|
+
"per_stay",
|
|
1504
|
+
"per_night",
|
|
1505
|
+
"per_person",
|
|
1506
|
+
"per_person_per_night",
|
|
1507
|
+
"unknown"
|
|
1508
|
+
]),
|
|
1509
|
+
collectedAt: openEnum(["property", "unknown"])
|
|
1510
|
+
})
|
|
1511
|
+
)
|
|
1512
|
+
});
|
|
1513
|
+
var cancellation = openObject({
|
|
1514
|
+
status: openEnum(["free", "partial_penalty", "non_refundable", "unknown"]),
|
|
1515
|
+
asOf: instant,
|
|
1516
|
+
freeCancellationEndsAt: instant.nullable(),
|
|
1517
|
+
penalties: z13.array(
|
|
1518
|
+
openObject({ startsAt: instant.nullable(), amount: money })
|
|
1519
|
+
),
|
|
1520
|
+
timeZone: z13.string().nullable()
|
|
1521
|
+
});
|
|
1522
|
+
var confirmation = openObject({
|
|
1523
|
+
mode: openEnum(["instant", "on_request", "unknown"]),
|
|
1524
|
+
withinMinutes: z13.number().int().nullable()
|
|
1525
|
+
});
|
|
1526
|
+
var offer = openObject({
|
|
1527
|
+
offerId,
|
|
1528
|
+
type: openEnum(["hotel"]),
|
|
1529
|
+
expiresAt: instant,
|
|
1530
|
+
hotel: openObject({
|
|
1531
|
+
hotelId,
|
|
1532
|
+
room: openObject({ name: localizedText.nullable(), ...roomFacts }),
|
|
1533
|
+
roomCount: z13.number().int(),
|
|
1534
|
+
meals
|
|
1535
|
+
}),
|
|
1536
|
+
price: openObject({
|
|
1537
|
+
total: money,
|
|
1538
|
+
perNightAllRooms: money,
|
|
1539
|
+
approx: openObject({
|
|
1540
|
+
total: money,
|
|
1541
|
+
perNightAllRooms: money,
|
|
1542
|
+
rateDate: localDate
|
|
1543
|
+
}).nullable(),
|
|
1544
|
+
payAtProperty
|
|
1545
|
+
}),
|
|
1546
|
+
cancellation,
|
|
1547
|
+
confirmation
|
|
1548
|
+
});
|
|
1549
|
+
var omitted = openObject({
|
|
1550
|
+
byNationality: z13.number().int(),
|
|
1551
|
+
byFilters: z13.number().int(),
|
|
1552
|
+
dominated: z13.number().int(),
|
|
1553
|
+
unreadable: z13.number().int(),
|
|
1554
|
+
byLimit: z13.number().int()
|
|
1555
|
+
});
|
|
1556
|
+
var UNAVAILABLE_REASONS = [
|
|
1557
|
+
"sold_out",
|
|
1558
|
+
"no_matching_offers",
|
|
1559
|
+
"nationality_restricted",
|
|
1560
|
+
"offers_unreadable",
|
|
1561
|
+
"not_found",
|
|
1562
|
+
"supplier_unavailable",
|
|
1563
|
+
"stay_in_past"
|
|
1564
|
+
];
|
|
1565
|
+
var hotelOffersResult = openObject({
|
|
1566
|
+
stay: stayEcho,
|
|
1567
|
+
hotels: z13.array(
|
|
1568
|
+
openObject({
|
|
1569
|
+
hotel: openObject({ hotelId, name: z13.string(), names }),
|
|
1570
|
+
offers: z13.array(offer),
|
|
1571
|
+
omitted
|
|
1572
|
+
})
|
|
1573
|
+
),
|
|
1574
|
+
unavailable: z13.array(
|
|
1575
|
+
openObject({
|
|
1576
|
+
hotelId,
|
|
1577
|
+
reason: openEnum(UNAVAILABLE_REASONS),
|
|
1578
|
+
omitted: omitted.nullable()
|
|
1579
|
+
})
|
|
1580
|
+
)
|
|
870
1581
|
});
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1582
|
+
|
|
1583
|
+
// src/outputs/order.ts
|
|
1584
|
+
import { z as z14 } from "zod";
|
|
1585
|
+
var ORDER_STATUSES = [
|
|
1586
|
+
"awaiting_payment",
|
|
1587
|
+
"expired",
|
|
1588
|
+
"confirming",
|
|
1589
|
+
"confirmed",
|
|
1590
|
+
"failed",
|
|
1591
|
+
"cancelling",
|
|
1592
|
+
"cancelled",
|
|
1593
|
+
"needs_attention"
|
|
1594
|
+
];
|
|
1595
|
+
var PAYMENT_STATUSES = [
|
|
1596
|
+
"unpaid",
|
|
1597
|
+
"authorized",
|
|
1598
|
+
"paid",
|
|
1599
|
+
"released",
|
|
1600
|
+
"refund_pending",
|
|
1601
|
+
"refunded"
|
|
1602
|
+
];
|
|
1603
|
+
var orderReference = z14.string().regex(/^FT-[2-9A-HJ-NP-Z]{6}$/);
|
|
1604
|
+
var hotelItem = openObject({
|
|
1605
|
+
type: z14.literal("hotel"),
|
|
1606
|
+
hotel: openObject({
|
|
1607
|
+
hotelId: hotelId.nullable(),
|
|
1608
|
+
name: z14.string(),
|
|
1609
|
+
names,
|
|
1610
|
+
checkIn: localDate,
|
|
1611
|
+
checkOut: localDate,
|
|
1612
|
+
timeZone: z14.string().nullable(),
|
|
1613
|
+
rooms: stayEcho.shape.rooms,
|
|
1614
|
+
room: openObject({
|
|
1615
|
+
name: localizedText.nullable(),
|
|
1616
|
+
beds: localizedText.nullable()
|
|
1617
|
+
}),
|
|
1618
|
+
meals,
|
|
1619
|
+
cancellation,
|
|
1620
|
+
payAtProperty,
|
|
1621
|
+
confirmationNumber: z14.string().nullable()
|
|
1622
|
+
})
|
|
1623
|
+
});
|
|
1624
|
+
var order = openObject({
|
|
1625
|
+
orderId,
|
|
1626
|
+
reference: orderReference,
|
|
1627
|
+
status: openEnum(ORDER_STATUSES),
|
|
1628
|
+
createdAt: instant,
|
|
1629
|
+
updatedAt: instant,
|
|
1630
|
+
items: z14.array(openUnion("type", [hotelItem])).min(1),
|
|
1631
|
+
price: openObject({ total: money }),
|
|
1632
|
+
payment: openObject({
|
|
1633
|
+
status: openEnum(PAYMENT_STATUSES),
|
|
1634
|
+
url: z14.string().nullable(),
|
|
1635
|
+
requiresSignIn: z14.boolean(),
|
|
1636
|
+
expiresAt: instant.nullable(),
|
|
1637
|
+
chargeCurrency: z14.string(),
|
|
1638
|
+
localCurrencyAtCheckout: z14.boolean(),
|
|
1639
|
+
localCharge: money.nullable()
|
|
1640
|
+
}),
|
|
1641
|
+
manageUrl: z14.string()
|
|
875
1642
|
});
|
|
876
|
-
var
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
scopes: z4.string(),
|
|
880
|
-
deviceAuthorizationEndpoint: z4.string(),
|
|
881
|
-
tokenEndpoint: z4.string(),
|
|
882
|
-
revocationEndpoint: z4.string()
|
|
1643
|
+
var orderList = openObject({
|
|
1644
|
+
data: z14.array(order),
|
|
1645
|
+
nextCursor: z14.string().nullable()
|
|
883
1646
|
});
|
|
1647
|
+
function change(field, value) {
|
|
1648
|
+
return openObject({
|
|
1649
|
+
field: z14.literal(field),
|
|
1650
|
+
previous: value,
|
|
1651
|
+
current: value,
|
|
1652
|
+
worse: z14.boolean()
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
var offerChange = openUnion("field", [
|
|
1656
|
+
change("price", money),
|
|
1657
|
+
change("meals", meals),
|
|
1658
|
+
change("cancellation", cancellation),
|
|
1659
|
+
change("confirmation", confirmation),
|
|
1660
|
+
change("pay_at_property", payAtProperty)
|
|
1661
|
+
]);
|
|
1662
|
+
|
|
1663
|
+
// src/outputs/error-details.ts
|
|
1664
|
+
var count2 = z15.number().int();
|
|
1665
|
+
var ERROR_DETAILS = {
|
|
1666
|
+
invalid_request: openObject({
|
|
1667
|
+
issues: z15.array(openObject({ pointer: z15.string(), message: z15.string() })),
|
|
1668
|
+
filterable: z15.array(openEnum(AMENITY_CODES)).optional(),
|
|
1669
|
+
share: openObject({
|
|
1670
|
+
code: openEnum(AMENITY_CODES),
|
|
1671
|
+
listedShare: z15.number().min(0).max(1)
|
|
1672
|
+
}).optional()
|
|
1673
|
+
}),
|
|
1674
|
+
cursor_invalid: openObject({
|
|
1675
|
+
reason: openEnum(["expired", "mismatch", "malformed"])
|
|
1676
|
+
}),
|
|
1677
|
+
unauthenticated: null,
|
|
1678
|
+
forbidden: null,
|
|
1679
|
+
not_found: null,
|
|
1680
|
+
order_removed: openObject({ orderId, reference: orderReference }),
|
|
1681
|
+
offer_expired: openObject({ offerId, expiredAt: instant }),
|
|
1682
|
+
offer_changed: openObject({ changes: z15.array(offerChange).min(1), offer }),
|
|
1683
|
+
offer_already_ordered: openObject({ orderId }),
|
|
1684
|
+
order_exists: openObject({ orderId, status: openEnum(ORDER_STATUSES) }),
|
|
1685
|
+
order_in_progress: openObject({ orderId }),
|
|
1686
|
+
order_attempts_exhausted: openObject({ orderId }),
|
|
1687
|
+
destination_ambiguous: openObject({
|
|
1688
|
+
name: z15.string(),
|
|
1689
|
+
countryCode: z15.string(),
|
|
1690
|
+
candidates: z15.array(
|
|
1691
|
+
openObject({
|
|
1692
|
+
...place.shape,
|
|
1693
|
+
hotels: openObject({
|
|
1694
|
+
scope: openEnum(["area", "radius"]),
|
|
1695
|
+
radiusMeters: z15.number().nullable(),
|
|
1696
|
+
count: count2
|
|
1697
|
+
})
|
|
1698
|
+
})
|
|
1699
|
+
),
|
|
1700
|
+
candidatesTotal: count2,
|
|
1701
|
+
nameAsText: count2.nullable()
|
|
1702
|
+
}),
|
|
1703
|
+
destination_not_found: openUnion("reason", [
|
|
1704
|
+
openObject({
|
|
1705
|
+
reason: z15.literal("no_match"),
|
|
1706
|
+
name: z15.string(),
|
|
1707
|
+
countryCode: z15.string(),
|
|
1708
|
+
nameAsText: count2.nullable()
|
|
1709
|
+
}),
|
|
1710
|
+
openObject({ reason: z15.literal("unknown_id"), placeId }),
|
|
1711
|
+
openObject({
|
|
1712
|
+
reason: z15.literal("not_a_locality"),
|
|
1713
|
+
placeId,
|
|
1714
|
+
hub: placeRef.nullable(),
|
|
1715
|
+
hubTotal: count2.nullable()
|
|
1716
|
+
})
|
|
1717
|
+
]),
|
|
1718
|
+
search_too_broad: openObject({
|
|
1719
|
+
matched: count2,
|
|
1720
|
+
poolLimit: count2,
|
|
1721
|
+
localities,
|
|
1722
|
+
/** The searched area's center, as a search echoes it; null for text only.
|
|
1723
|
+
* Optional for clients; the server must send it (`SERVER_DETAILS`). */
|
|
1724
|
+
center: center.nullable().optional()
|
|
1725
|
+
}),
|
|
1726
|
+
stay_unsupported: openUnion("reason", [
|
|
1727
|
+
openObject({ reason: z15.literal("mixed_occupancy") }),
|
|
1728
|
+
openObject({ reason: z15.literal("max_nights"), max: count2 })
|
|
1729
|
+
]),
|
|
1730
|
+
stay_in_past: openObject({
|
|
1731
|
+
checkIn: localDate,
|
|
1732
|
+
timeZone: z15.string().nullable()
|
|
1733
|
+
}),
|
|
1734
|
+
offer_unavailable: openObject({
|
|
1735
|
+
offerId,
|
|
1736
|
+
reason: openEnum(["sold_out", "refused"])
|
|
1737
|
+
}),
|
|
1738
|
+
rate_limited: openObject({
|
|
1739
|
+
scope: openEnum(["search", "offers", "hotel", "orders_read", "orders_create"])
|
|
1740
|
+
}),
|
|
1741
|
+
ordering_paused: null,
|
|
1742
|
+
service_unavailable: null,
|
|
1743
|
+
supplier_unavailable: openObject({
|
|
1744
|
+
outcome: openEnum(["not_started", "unknown"])
|
|
1745
|
+
}),
|
|
1746
|
+
internal: null
|
|
1747
|
+
};
|
|
1748
|
+
var SERVER_DETAILS = {
|
|
1749
|
+
search_too_broad: ERROR_DETAILS.search_too_broad.extend({ center: center.nullable() })
|
|
1750
|
+
};
|
|
1751
|
+
function envelopeSchema(detailsRequired) {
|
|
1752
|
+
return openObject({
|
|
1753
|
+
error: openUnion(
|
|
1754
|
+
"code",
|
|
1755
|
+
Object.keys(ERROR_CODES).map((code) => {
|
|
1756
|
+
const details = detailsRequired ? SERVER_DETAILS[code] ?? ERROR_DETAILS[code] : ERROR_DETAILS[code];
|
|
1757
|
+
return openObject({
|
|
1758
|
+
code: z15.literal(code),
|
|
1759
|
+
message: z15.string(),
|
|
1760
|
+
param: z15.string().optional(),
|
|
1761
|
+
retryable: z15.boolean(),
|
|
1762
|
+
retryAfterSeconds: z15.number().int().optional(),
|
|
1763
|
+
...details ? { details: detailsRequired ? details : details.optional() } : {},
|
|
1764
|
+
requestId: z15.string()
|
|
1765
|
+
});
|
|
1766
|
+
})
|
|
1767
|
+
)
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
var errorEnvelope = envelopeSchema(false);
|
|
1771
|
+
function strictErrorEnvelope() {
|
|
1772
|
+
return strictOutput(envelopeSchema(true));
|
|
1773
|
+
}
|
|
884
1774
|
|
|
885
1775
|
// src/paths.ts
|
|
1776
|
+
var PUBLIC_ORIGINS = {
|
|
1777
|
+
api: "https://api.fortrip.ai",
|
|
1778
|
+
mcp: "https://mcp.fortrip.ai"
|
|
1779
|
+
};
|
|
886
1780
|
var API = {
|
|
887
|
-
me: "/
|
|
888
|
-
authConfig: "/
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1781
|
+
me: "/v1/me",
|
|
1782
|
+
authConfig: "/v1/auth/config",
|
|
1783
|
+
hotelSearch: "/v1/hotels/search",
|
|
1784
|
+
hotelOffers: "/v1/hotels/offers",
|
|
1785
|
+
orders: "/v1/orders",
|
|
1786
|
+
hotel(id, opts) {
|
|
1787
|
+
const query = [];
|
|
1788
|
+
if (opts?.rooms) query.push("include=rooms");
|
|
1789
|
+
if (opts?.locale) query.push(`locale=${opts.locale}`);
|
|
1790
|
+
return `/v1/hotels/${encodeURIComponent(id)}${query.length ? `?${query.join("&")}` : ""}`;
|
|
1791
|
+
},
|
|
1792
|
+
order(id, opts) {
|
|
1793
|
+
return `/v1/orders/${encodeURIComponent(id)}${opts?.locale ? `?locale=${opts.locale}` : ""}`;
|
|
895
1794
|
}
|
|
896
1795
|
};
|
|
897
|
-
|
|
898
|
-
// src/reasons.ts
|
|
899
|
-
var reasonStatuses = {
|
|
900
|
-
already_booked: [409],
|
|
901
|
-
price_or_terms_changed: [409],
|
|
902
|
-
rate_unavailable: [422],
|
|
903
|
-
refused: [422],
|
|
904
|
-
supplier_unavailable: [503, 504]
|
|
905
|
-
};
|
|
906
|
-
var ERROR_REASONS = reasonStatuses;
|
|
1796
|
+
var MCP_PATH = "/";
|
|
907
1797
|
|
|
908
1798
|
// src/tools.ts
|
|
909
|
-
import { z as
|
|
910
|
-
var
|
|
911
|
-
var
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
})
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1799
|
+
import { z as z16 } from "zod";
|
|
1800
|
+
var ORDER_NEXT_STEP = "Before you give the user an order's payment.url, show them its total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term (meals, cancellation, fees payable at the property), then end your reply by asking them to confirm, and wait for a clear yes. Only after they say yes, give them payment.url exactly as returned.";
|
|
1801
|
+
var nextStep = z16.string().optional().describe(
|
|
1802
|
+
"What to show and ask before giving a payment.url. Present when the answer holds a non-null payment.url, and only then."
|
|
1803
|
+
);
|
|
1804
|
+
var MCP_OUTPUT = {
|
|
1805
|
+
book_hotel: order.extend({ nextStep }),
|
|
1806
|
+
get_order: order.extend({ nextStep }),
|
|
1807
|
+
list_orders: orderList.extend({ nextStep })
|
|
1808
|
+
};
|
|
1809
|
+
var READ = {
|
|
1810
|
+
readOnlyHint: true,
|
|
1811
|
+
idempotentHint: true,
|
|
1812
|
+
openWorldHint: true,
|
|
1813
|
+
destructiveHint: false
|
|
1814
|
+
};
|
|
1815
|
+
var READ_OWN = { ...READ, openWorldHint: false };
|
|
921
1816
|
var TOOLS = [
|
|
922
1817
|
{
|
|
923
1818
|
name: "get_planning_guide",
|
|
924
1819
|
title: "Get hotel planning guide",
|
|
925
1820
|
description: `Returns Fortrip's planning guide for a request that is more than one hotel for fixed dates (flexible dates, a budget, split stays, several destinations, "find me the best"): what to ask the user, which results count as evidence, how to rank and how to present options.`,
|
|
926
|
-
annotations:
|
|
927
|
-
readOnlyHint: true,
|
|
928
|
-
idempotentHint: true,
|
|
929
|
-
openWorldHint: false,
|
|
930
|
-
destructiveHint: false
|
|
931
|
-
},
|
|
1821
|
+
annotations: READ_OWN,
|
|
932
1822
|
input: null,
|
|
933
1823
|
output: null
|
|
934
1824
|
},
|
|
935
1825
|
{
|
|
936
1826
|
name: "search_hotels",
|
|
937
1827
|
title: "Search hotels",
|
|
938
|
-
description:
|
|
939
|
-
annotations:
|
|
940
|
-
readOnlyHint: true,
|
|
941
|
-
idempotentHint: true,
|
|
942
|
-
openWorldHint: true,
|
|
943
|
-
destructiveHint: false
|
|
944
|
-
},
|
|
1828
|
+
description: 'Find hotels by destination and/or text, with an indicative per-room-night price when a stay is given. destination is a place_name with its countryCode, a place or locality by placeId from an earlier answer, a point, or a country. A name several places bear answers destination_ambiguous with candidates: ask the user, then search the chosen placeId. text matches hotel names, brands and areas word for word. Indicative prices are estimates and not bookable: get_hotel_offers returns what the user pays. A search too broad to answer is refused with search_too_broad and its localities: pass one back as { kind: "locality", placeId }, never by name. The answer states the scope, the counts and notes; a next page is the cursor and, optionally, limit.',
|
|
1829
|
+
annotations: READ,
|
|
945
1830
|
input: searchHotelsInput,
|
|
946
|
-
output:
|
|
1831
|
+
output: searchResult
|
|
947
1832
|
},
|
|
948
1833
|
{
|
|
949
1834
|
name: "get_hotel",
|
|
950
1835
|
title: "Get hotel details",
|
|
951
|
-
description:
|
|
952
|
-
annotations:
|
|
953
|
-
readOnlyHint: true,
|
|
954
|
-
idempotentHint: true,
|
|
955
|
-
openWorldHint: true,
|
|
956
|
-
destructiveHint: false
|
|
957
|
-
},
|
|
1836
|
+
description: `One hotel's catalogue facts: names, address, location, time zone, check-in and check-out times, the full description, every policy and the amenities, and its rooms with include: "rooms". "Not listed" is not "no". No prices or availability: get_hotel_offers has those.`,
|
|
1837
|
+
annotations: READ,
|
|
958
1838
|
input: getHotelInput,
|
|
959
|
-
output:
|
|
1839
|
+
output: hotel
|
|
960
1840
|
},
|
|
961
1841
|
{
|
|
962
|
-
name: "
|
|
963
|
-
title: "Get live hotel
|
|
964
|
-
description: `Live
|
|
965
|
-
annotations:
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
openWorldHint: true,
|
|
969
|
-
destructiveHint: false
|
|
970
|
-
},
|
|
971
|
-
input: MCP_RATES_INPUT,
|
|
972
|
-
output: null
|
|
1842
|
+
name: "get_hotel_offers",
|
|
1843
|
+
title: "Get live hotel offers",
|
|
1844
|
+
description: `Live, bookable offers for up to ${LIMITS.offersHotels} hotels and one stay. Every room in stay.rooms must have the same party: for different parties, call once per party. Each offer has the room, the meals included, price.total in USD for the whole stay and every room (what is charged), fees payable at the property, the cancellation terms with times in UTC and the hotel's time zone, the confirmation mode, and an offerId for book_hotel that expires at expiresAt. Hotels with nothing to offer are listed under unavailable with the reason. Up to limitPerHotel offers per hotel, default ${LIMITS.offersPerHotelDefault}, cheapest first.`,
|
|
1845
|
+
annotations: READ,
|
|
1846
|
+
input: hotelOffersInput,
|
|
1847
|
+
output: hotelOffersResult
|
|
973
1848
|
},
|
|
974
1849
|
{
|
|
975
1850
|
name: "book_hotel",
|
|
976
|
-
title: "Create hotel
|
|
977
|
-
description: `
|
|
1851
|
+
title: "Create hotel order and payment link",
|
|
1852
|
+
description: `Order one offer from get_hotel_offers. The answer is an unpaid order with a payment link: status awaiting_payment, the total, the stay and its terms, and payment.url, a Fortrip page where the user pays. Nothing is charged until the user pays there; anyone holding the link can pay. Send the contact and, for each room of the offer, at least one guest. If the price or terms changed since the offer, the answer is offer_changed with every change and a new offer: show the changes and ask the user again before ordering it. Sending the same details again returns the same order. This tool can't change or cancel an order: for changes, cancellations or refunds the user emails ${SUPPORT_EMAIL} with the order's reference.`,
|
|
978
1853
|
annotations: {
|
|
979
1854
|
readOnlyHint: false,
|
|
980
|
-
idempotentHint:
|
|
1855
|
+
idempotentHint: false,
|
|
981
1856
|
openWorldHint: true,
|
|
982
1857
|
destructiveHint: false
|
|
983
1858
|
},
|
|
984
|
-
input:
|
|
985
|
-
output:
|
|
1859
|
+
input: createOrderInput,
|
|
1860
|
+
output: MCP_OUTPUT.book_hotel
|
|
1861
|
+
},
|
|
1862
|
+
{
|
|
1863
|
+
name: "get_order",
|
|
1864
|
+
title: "Get order",
|
|
1865
|
+
description: "One of the user's orders by orderId: its status, the payment status and, while it awaits payment, payment.url; the stay and its terms; and the hotel's confirmation number once there is one. Check it after the user pays. An order awaiting payment whose payment.url is null had its link unreadable this time: read it again.",
|
|
1866
|
+
annotations: READ_OWN,
|
|
1867
|
+
input: getOrderInput,
|
|
1868
|
+
output: MCP_OUTPUT.get_order
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
name: "list_orders",
|
|
1872
|
+
title: "List orders",
|
|
1873
|
+
description: "The user's hotel orders, newest first, including those made on the Fortrip website. Pass nextCursor back as cursor for the next page.",
|
|
1874
|
+
annotations: READ_OWN,
|
|
1875
|
+
input: listOrdersInput,
|
|
1876
|
+
output: MCP_OUTPUT.list_orders
|
|
986
1877
|
}
|
|
987
1878
|
];
|
|
988
1879
|
|
|
1880
|
+
// src/render/format.ts
|
|
1881
|
+
function num(n) {
|
|
1882
|
+
return n.toLocaleString("en-US");
|
|
1883
|
+
}
|
|
1884
|
+
function km(meters) {
|
|
1885
|
+
return `${(meters / 1e3).toFixed(1)} km`;
|
|
1886
|
+
}
|
|
1887
|
+
function money2(m) {
|
|
1888
|
+
return `${m.currency} ${m.amount}`;
|
|
1889
|
+
}
|
|
1890
|
+
function plural(n, one, many = `${one}s`) {
|
|
1891
|
+
return `${num(n)} ${n === 1 ? one : many}`;
|
|
1892
|
+
}
|
|
1893
|
+
function named(name, names2) {
|
|
1894
|
+
const other = [names2.en, names2.zh].find((n) => n && n !== name);
|
|
1895
|
+
return other ? `${name} (${other})` : name;
|
|
1896
|
+
}
|
|
1897
|
+
function label(words, value) {
|
|
1898
|
+
return Object.hasOwn(words, value) ? words[value] : value;
|
|
1899
|
+
}
|
|
1900
|
+
function stayLine(checkIn, checkOut) {
|
|
1901
|
+
const nights = countIsoDateDays(checkIn, checkOut);
|
|
1902
|
+
return `${checkIn} \u2192 ${checkOut} (${plural(nights, "night")})`;
|
|
1903
|
+
}
|
|
1904
|
+
function partyOf(room) {
|
|
1905
|
+
const adults = plural(room.adults, "adult");
|
|
1906
|
+
const n = room.childrenAges.length;
|
|
1907
|
+
if (n === 0) return adults;
|
|
1908
|
+
const ages = room.childrenAges.join(", ");
|
|
1909
|
+
return `${adults} and ${plural(n, "child", "children")} (${n === 1 ? "age" : "ages"} ${ages})`;
|
|
1910
|
+
}
|
|
1911
|
+
function party(rooms) {
|
|
1912
|
+
const parties = rooms.map(partyOf);
|
|
1913
|
+
if (parties.length === 1) return `1 room: ${parties[0]}`;
|
|
1914
|
+
if (parties.every((p) => p === parties[0])) {
|
|
1915
|
+
return `${parties.length} rooms, each ${parties[0]}`;
|
|
1916
|
+
}
|
|
1917
|
+
return `${parties.length} rooms: ${parties.map((p, i) => `room ${i + 1} ${p}`).join("; ")}`;
|
|
1918
|
+
}
|
|
1919
|
+
function when(instant2, timeZone2) {
|
|
1920
|
+
const utc = `${instant2.slice(0, 10)} ${instant2.slice(11, 16)} UTC`;
|
|
1921
|
+
if (!timeZone2) return utc;
|
|
1922
|
+
try {
|
|
1923
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
1924
|
+
timeZone: timeZone2,
|
|
1925
|
+
year: "numeric",
|
|
1926
|
+
month: "2-digit",
|
|
1927
|
+
day: "2-digit",
|
|
1928
|
+
hour: "2-digit",
|
|
1929
|
+
minute: "2-digit",
|
|
1930
|
+
hourCycle: "h23"
|
|
1931
|
+
}).formatToParts(new Date(instant2));
|
|
1932
|
+
const part = (type) => parts.find((p) => p.type === type)?.value;
|
|
1933
|
+
return `${utc} (${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")} ${timeZone2})`;
|
|
1934
|
+
} catch {
|
|
1935
|
+
return utc;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
function raw(value) {
|
|
1939
|
+
try {
|
|
1940
|
+
return JSON.stringify(value) ?? "no value";
|
|
1941
|
+
} catch {
|
|
1942
|
+
return String(value);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
var WINDOWS = {
|
|
1946
|
+
yes: "window",
|
|
1947
|
+
no: "no window",
|
|
1948
|
+
partial: "window in some rooms"
|
|
1949
|
+
};
|
|
1950
|
+
function roomFacts2(r) {
|
|
1951
|
+
const area = r.areaSqm ? r.areaSqm.min === r.areaSqm.max ? `${r.areaSqm.min} m\xB2` : `${r.areaSqm.min}\u2013${r.areaSqm.max} m\xB2` : "size not stated";
|
|
1952
|
+
return [
|
|
1953
|
+
r.beds ? `beds: ${r.beds.text}` : "beds not listed",
|
|
1954
|
+
area,
|
|
1955
|
+
r.maxOccupancy === null ? "max occupancy not listed" : `max ${plural(r.maxOccupancy, "guest")}`,
|
|
1956
|
+
r.window === null ? "window not listed" : label(WINDOWS, r.window)
|
|
1957
|
+
];
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// src/render/hotel.ts
|
|
1961
|
+
function times(what, t) {
|
|
1962
|
+
if (t.from === null && t.until === null) return `${what}: not listed`;
|
|
1963
|
+
return `${what} from ${t.from ?? "not listed"}, until ${t.until ?? "not listed"}`;
|
|
1964
|
+
}
|
|
1965
|
+
function deposit(d) {
|
|
1966
|
+
if (d.required === null) return "Deposit: not stated";
|
|
1967
|
+
if (!d.required) return "Deposit: not required";
|
|
1968
|
+
return `Deposit: required, ${d.amount ? money2(d.amount) : "amount not stated"}`;
|
|
1969
|
+
}
|
|
1970
|
+
function roomLine(r) {
|
|
1971
|
+
return [`- ${r.name.text}`, ...roomFacts2(r)].join(" \xB7 ");
|
|
1972
|
+
}
|
|
1973
|
+
function renderHotel(h) {
|
|
1974
|
+
const region = h.address.region;
|
|
1975
|
+
const lines = [
|
|
1976
|
+
[
|
|
1977
|
+
named(h.name, h.names),
|
|
1978
|
+
h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
|
|
1979
|
+
h.hotelId,
|
|
1980
|
+
h.bookable ? "" : "no longer bookable (it has left the catalogue)"
|
|
1981
|
+
].filter(Boolean).join(" \xB7 "),
|
|
1982
|
+
[
|
|
1983
|
+
`Address: ${h.address.line ? h.address.line.text : "not listed"}`,
|
|
1984
|
+
region ? `${region.name} ${region.placeId}` : "",
|
|
1985
|
+
h.address.countryCode ?? "",
|
|
1986
|
+
h.location ? `${h.location.lat}, ${h.location.lng}` : "location not listed",
|
|
1987
|
+
`time zone ${h.timeZone ?? "not listed"}`
|
|
1988
|
+
].filter(Boolean).join(" \xB7 "),
|
|
1989
|
+
`${times("Check-in", h.checkInTimes)} \xB7 ${times("Check-out", h.checkOutTimes)} (local time)`,
|
|
1990
|
+
`Breakfast served: ${h.breakfastServed === null ? "not stated" : h.breakfastServed ? "yes" : "no"}`,
|
|
1991
|
+
deposit(h.deposit),
|
|
1992
|
+
`Phone: ${h.contact.phone ?? "not listed"} \xB7 Website: ${h.contact.website ?? "not listed"}`
|
|
1993
|
+
];
|
|
1994
|
+
if (!h.amenities.listed) {
|
|
1995
|
+
lines.push('Amenities: not listed (unknown, not "none")');
|
|
1996
|
+
} else if (h.amenities.items.length === 0) {
|
|
1997
|
+
lines.push("Amenities: none listed");
|
|
1998
|
+
} else {
|
|
1999
|
+
const items = h.amenities.items.map((a) => `${a.name.text} (${a.code})`);
|
|
2000
|
+
lines.push(`Amenities listed (others may exist): ${items.join(", ")}`);
|
|
2001
|
+
}
|
|
2002
|
+
if (!h.policies.listed) {
|
|
2003
|
+
lines.push("Policies: not listed");
|
|
2004
|
+
} else if (h.policies.items.length === 0) {
|
|
2005
|
+
lines.push("Policies: none listed");
|
|
2006
|
+
} else {
|
|
2007
|
+
lines.push("Policies:", ...h.policies.items.map((p) => `- ${p.topic}: ${p.text}`));
|
|
2008
|
+
}
|
|
2009
|
+
if (h.rooms) {
|
|
2010
|
+
if (!h.rooms.listed) lines.push("Rooms: not listed");
|
|
2011
|
+
else if (h.rooms.items.length === 0) lines.push("Rooms: none listed");
|
|
2012
|
+
else lines.push("Rooms:", ...h.rooms.items.map(roomLine));
|
|
2013
|
+
}
|
|
2014
|
+
if (h.description === null) {
|
|
2015
|
+
lines.push("Description: not listed");
|
|
2016
|
+
} else {
|
|
2017
|
+
lines.push(`Description (${h.description.locale}):`, h.description.text);
|
|
2018
|
+
}
|
|
2019
|
+
return lines.join("\n");
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/render/offers.ts
|
|
2023
|
+
var MEALS_UNKNOWN = "meals not stated by the supplier \u2014 not known to be room only, may include breakfast; ask or check at booking";
|
|
2024
|
+
function mealsText(m) {
|
|
2025
|
+
if (!m.known) return MEALS_UNKNOWN;
|
|
2026
|
+
if (m.included === null) return "meals not stated";
|
|
2027
|
+
let base;
|
|
2028
|
+
if (m.allInclusive) {
|
|
2029
|
+
base = "all inclusive";
|
|
2030
|
+
} else {
|
|
2031
|
+
const extra = m.unspecifiedMainMeals;
|
|
2032
|
+
const parts = [...m.included];
|
|
2033
|
+
if (extra !== null && extra > 0) parts.push(`${plural(extra, "more main meal")} (not named)`);
|
|
2034
|
+
if (parts.length === 0 && extra === 0) return "room only";
|
|
2035
|
+
base = parts.length > 0 ? parts.join(" + ") : "no meals named";
|
|
2036
|
+
if (extra === null) base += "; other main meals not stated";
|
|
2037
|
+
}
|
|
2038
|
+
return m.guestsCoveredPerRoom === null ? `${base}, guests covered not stated` : `${base} for ${plural(m.guestsCoveredPerRoom, "guest")} per room`;
|
|
2039
|
+
}
|
|
2040
|
+
function mealsWithLabel(m) {
|
|
2041
|
+
const text2 = mealsText(m);
|
|
2042
|
+
return m.label ? `${text2} (labelled "${m.label.text}")` : text2;
|
|
2043
|
+
}
|
|
2044
|
+
function cancellationText(c) {
|
|
2045
|
+
if (c.status === "unknown") return "cancellation terms unknown";
|
|
2046
|
+
const steps = c.penalties.map(
|
|
2047
|
+
(p) => `${money2(p.amount)} from ${p.startsAt === null ? "booking" : when(p.startsAt, c.timeZone)}`
|
|
2048
|
+
);
|
|
2049
|
+
if (c.status === "free") {
|
|
2050
|
+
const head2 = c.freeCancellationEndsAt === null ? "free cancellation until check-in" : `free cancellation until ${when(c.freeCancellationEndsAt, c.timeZone)}`;
|
|
2051
|
+
return steps.length ? `${head2}, then ${steps.join("; ")}` : head2;
|
|
2052
|
+
}
|
|
2053
|
+
const head = label(
|
|
2054
|
+
{ partial_penalty: "partial penalty now", non_refundable: "non-refundable" },
|
|
2055
|
+
c.status
|
|
2056
|
+
);
|
|
2057
|
+
return steps.length ? `${head}: ${steps.join("; ")}` : head;
|
|
2058
|
+
}
|
|
2059
|
+
var BASES = {
|
|
2060
|
+
per_stay: "per stay",
|
|
2061
|
+
per_night: "per night",
|
|
2062
|
+
per_person: "per person",
|
|
2063
|
+
per_person_per_night: "per person per night",
|
|
2064
|
+
unknown: "basis unknown"
|
|
2065
|
+
};
|
|
2066
|
+
function feesText(p) {
|
|
2067
|
+
const items = p.items.map(
|
|
2068
|
+
(i) => [
|
|
2069
|
+
`${i.label ? i.label.text : "unnamed fee"} ${i.amount ? money2(i.amount) : "amount not stated"}`,
|
|
2070
|
+
label(BASES, i.basis),
|
|
2071
|
+
i.mandatory ? "mandatory" : "optional",
|
|
2072
|
+
i.collectedAt === "property" ? "at the property" : i.collectedAt === "unknown" ? "collection not stated (could be prepaid or at the property)" : `collected: ${i.collectedAt}`
|
|
2073
|
+
].filter(Boolean).join(", ")
|
|
2074
|
+
);
|
|
2075
|
+
const listed = `fees not in the total: ${items.join("; ")}`;
|
|
2076
|
+
if (p.disclosure === "complete") {
|
|
2077
|
+
return items.length ? listed : "nothing to pay at the property";
|
|
2078
|
+
}
|
|
2079
|
+
const caveat = p.disclosure === "incomplete" ? "fees not fully disclosed (amounts or how they are collected may be missing)" : `fees not in the total: disclosure ${p.disclosure}`;
|
|
2080
|
+
return items.length ? `${listed}; ${caveat}` : caveat;
|
|
2081
|
+
}
|
|
2082
|
+
function confirmationText(c) {
|
|
2083
|
+
if (c.mode === "instant") return "instant confirmation";
|
|
2084
|
+
if (c.mode === "on_request") {
|
|
2085
|
+
return c.withinMinutes === null ? "confirmation on request" : `confirmation on request, within ${c.withinMinutes} min`;
|
|
2086
|
+
}
|
|
2087
|
+
if (c.mode === "unknown") return "confirmation mode unknown";
|
|
2088
|
+
return `confirmation ${c.mode}`;
|
|
2089
|
+
}
|
|
2090
|
+
function roomText(r) {
|
|
2091
|
+
return [r.name ? r.name.text : "room name not listed", ...roomFacts2(r)].join(", ");
|
|
2092
|
+
}
|
|
2093
|
+
function offerLine(o) {
|
|
2094
|
+
const p = o.price;
|
|
2095
|
+
let price = `${money2(p.total)} total (${money2(p.perNightAllRooms)} per night, all rooms)`;
|
|
2096
|
+
if (p.approx) {
|
|
2097
|
+
price += ` \u2248 ${money2(p.approx.total)} (${money2(p.approx.perNightAllRooms)} per night), exchange rate of ${p.approx.rateDate}`;
|
|
2098
|
+
}
|
|
2099
|
+
return [
|
|
2100
|
+
`${o.offerId}${o.type === "hotel" ? "" : ` (${o.type})`}`,
|
|
2101
|
+
`${plural(o.hotel.roomCount, "room")}: ${roomText(o.hotel.room)}`,
|
|
2102
|
+
price,
|
|
2103
|
+
mealsWithLabel(o.hotel.meals),
|
|
2104
|
+
cancellationText(o.cancellation),
|
|
2105
|
+
confirmationText(o.confirmation),
|
|
2106
|
+
feesText(p.payAtProperty),
|
|
2107
|
+
`offer expires ${when(o.expiresAt, null)}`
|
|
2108
|
+
].join(" \xB7 ");
|
|
2109
|
+
}
|
|
2110
|
+
var OMITTED = {
|
|
2111
|
+
byNationality: "restricted by nationality",
|
|
2112
|
+
byFilters: "removed by the filters",
|
|
2113
|
+
dominated: "beaten by another offer for the same room",
|
|
2114
|
+
unreadable: "unreadable"
|
|
2115
|
+
};
|
|
2116
|
+
function limitText(o) {
|
|
2117
|
+
const n = o.byLimit;
|
|
2118
|
+
return typeof n === "number" && n > 0 ? `${plural(n, "more offer")} not shown (limitPerHotel); a filtered request (requireBreakfast / requireFreeCancellation) or a higher limitPerHotel may show them \u2014 don't conclude they don't exist` : "";
|
|
2119
|
+
}
|
|
2120
|
+
function omittedText(o) {
|
|
2121
|
+
return Object.entries(OMITTED).filter(([key]) => typeof o[key] === "number" && o[key] > 0).map(([key, words]) => `${o[key]} ${words}`).join(", ");
|
|
2122
|
+
}
|
|
2123
|
+
var UNAVAILABLE = {
|
|
2124
|
+
sold_out: "sold out",
|
|
2125
|
+
no_matching_offers: "no offer matches the filters",
|
|
2126
|
+
nationality_restricted: "restricted by nationality",
|
|
2127
|
+
offers_unreadable: "offers unreadable",
|
|
2128
|
+
not_found: "hotel not found",
|
|
2129
|
+
supplier_unavailable: "offers could not be fetched this time",
|
|
2130
|
+
stay_in_past: "check-in is already past"
|
|
2131
|
+
};
|
|
2132
|
+
function renderOffers(r) {
|
|
2133
|
+
const lines = [
|
|
2134
|
+
`Live offers \xB7 ${stayLine(r.stay.checkIn, r.stay.checkOut)} \xB7 ${party(r.stay.rooms)}`,
|
|
2135
|
+
"total = the whole stay and every room, the amount charged. Fees not in the total are in the property's currency, not converted. Times are UTC, then the hotel's time zone."
|
|
2136
|
+
];
|
|
2137
|
+
if (r.hotels.length === 0) lines.push("", "No hotel has offers for this stay.");
|
|
2138
|
+
for (const h of r.hotels) {
|
|
2139
|
+
lines.push("", `${named(h.hotel.name, h.hotel.names)} \xB7 ${h.hotel.hotelId} \xB7 ${plural(h.offers.length, "offer")}`);
|
|
2140
|
+
h.offers.forEach((o, i) => lines.push(`${i + 1}. ${offerLine(o)}`));
|
|
2141
|
+
const omitted2 = omittedText(h.omitted);
|
|
2142
|
+
if (omitted2) lines.push(`Not shown: ${omitted2}.`);
|
|
2143
|
+
const limited = limitText(h.omitted);
|
|
2144
|
+
if (limited) lines.push(`${limited}.`);
|
|
2145
|
+
}
|
|
2146
|
+
if (r.unavailable.length > 0) {
|
|
2147
|
+
lines.push("", "Unavailable:");
|
|
2148
|
+
for (const u of r.unavailable) {
|
|
2149
|
+
const omitted2 = u.omitted ? [omittedText(u.omitted), limitText(u.omitted)].filter(Boolean).join("; ") : "";
|
|
2150
|
+
lines.push(`- ${u.hotelId}: ${label(UNAVAILABLE, u.reason)}${omitted2 ? ` (${omitted2})` : ""}`);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
return lines.join("\n");
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/render/order.ts
|
|
2157
|
+
var ORDER_STATUS_WORDS = {
|
|
2158
|
+
awaiting_payment: "awaiting payment",
|
|
2159
|
+
expired: "expired (not paid in time)",
|
|
2160
|
+
confirming: "confirming with the hotel",
|
|
2161
|
+
confirmed: "confirmed",
|
|
2162
|
+
failed: "failed",
|
|
2163
|
+
cancelling: "cancelling",
|
|
2164
|
+
cancelled: "cancelled",
|
|
2165
|
+
needs_attention: "needs attention from Fortrip"
|
|
2166
|
+
};
|
|
2167
|
+
var PAYMENT_WORDS = {
|
|
2168
|
+
unpaid: "unpaid",
|
|
2169
|
+
authorized: "authorized (held, not yet charged)",
|
|
2170
|
+
paid: "paid",
|
|
2171
|
+
released: "released (the hold was let go)",
|
|
2172
|
+
refund_pending: "refund pending",
|
|
2173
|
+
refunded: "refunded"
|
|
2174
|
+
};
|
|
2175
|
+
function itemLines(item) {
|
|
2176
|
+
if (item.type !== "hotel") {
|
|
2177
|
+
return [`Item of type ${item.type}: not shown in this text. ${raw(item)}`];
|
|
2178
|
+
}
|
|
2179
|
+
const h = item.hotel;
|
|
2180
|
+
return [
|
|
2181
|
+
[
|
|
2182
|
+
`Hotel: ${named(h.name, h.names)}`,
|
|
2183
|
+
h.hotelId ?? "not in the hotel catalogue",
|
|
2184
|
+
stayLine(h.checkIn, h.checkOut),
|
|
2185
|
+
party(h.rooms),
|
|
2186
|
+
`time zone ${h.timeZone ?? "not listed"}`
|
|
2187
|
+
].join(" \xB7 "),
|
|
2188
|
+
`Room: ${h.room.name ? h.room.name.text : "room name not listed"} \xB7 ${h.room.beds ? `beds: ${h.room.beds.text}` : "beds not listed"}`,
|
|
2189
|
+
`Terms: ${mealsWithLabel(h.meals)} \xB7 ${cancellationText(h.cancellation)} \xB7 ${feesText(h.payAtProperty)}`,
|
|
2190
|
+
`Hotel confirmation number: ${h.confirmationNumber ?? "none yet"}`
|
|
2191
|
+
];
|
|
2192
|
+
}
|
|
2193
|
+
function paymentLines(o) {
|
|
2194
|
+
const p = o.payment;
|
|
2195
|
+
const lines = [
|
|
2196
|
+
[
|
|
2197
|
+
`Total: ${money2(o.price.total)}`,
|
|
2198
|
+
`payment ${label(PAYMENT_WORDS, p.status)}`,
|
|
2199
|
+
`charged in ${p.chargeCurrency}${p.localCurrencyAtCheckout ? " (the user's card may pay in its local currency at checkout)" : ""}`,
|
|
2200
|
+
p.localCharge ? `card charged ${money2(p.localCharge)}` : ""
|
|
2201
|
+
].filter(Boolean).join(" \xB7 ")
|
|
2202
|
+
];
|
|
2203
|
+
if (p.url !== null) {
|
|
2204
|
+
const about = [
|
|
2205
|
+
p.expiresAt === null ? "" : `expires ${when(p.expiresAt, null)}`,
|
|
2206
|
+
p.requiresSignIn ? "the user signs in to Fortrip to pay" : "no sign-in needed"
|
|
2207
|
+
].filter(Boolean);
|
|
2208
|
+
lines.push(`Payment link (${about.join("; ")}): ${p.url}`);
|
|
2209
|
+
} else if (o.status === "awaiting_payment") {
|
|
2210
|
+
lines.push("Payment link: could not be read this time; read this order again for it.");
|
|
2211
|
+
}
|
|
2212
|
+
lines.push(`Manage: ${o.manageUrl}`);
|
|
2213
|
+
return lines;
|
|
2214
|
+
}
|
|
2215
|
+
function orderLines(o) {
|
|
2216
|
+
return [
|
|
2217
|
+
`Order ${o.orderId} \xB7 reference ${o.reference} \xB7 ${label(ORDER_STATUS_WORDS, o.status)} \xB7 created ${when(o.createdAt, null)} \xB7 updated ${when(o.updatedAt, null)}`,
|
|
2218
|
+
...o.items.flatMap(itemLines),
|
|
2219
|
+
...paymentLines(o)
|
|
2220
|
+
];
|
|
2221
|
+
}
|
|
2222
|
+
function renderOrder(o) {
|
|
2223
|
+
return orderLines(o).join("\n");
|
|
2224
|
+
}
|
|
2225
|
+
function renderOrderList(list) {
|
|
2226
|
+
const lines = [
|
|
2227
|
+
list.data.length === 0 ? "No orders on this page." : `Orders, newest first: ${list.data.length} on this page.`
|
|
2228
|
+
];
|
|
2229
|
+
for (const o of list.data) lines.push("", ...orderLines(o));
|
|
2230
|
+
lines.push(
|
|
2231
|
+
"",
|
|
2232
|
+
list.nextCursor === null ? "Last page." : `Next page: call again with { cursor: "${list.nextCursor}" }.`
|
|
2233
|
+
);
|
|
2234
|
+
return lines.join("\n");
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// src/render/search.ts
|
|
2238
|
+
var SORTS = {
|
|
2239
|
+
distance: "nearest first",
|
|
2240
|
+
relevance: "best text match first",
|
|
2241
|
+
stars_desc: "most stars first",
|
|
2242
|
+
price_asc: "cheapest first",
|
|
2243
|
+
price_desc: "dearest first"
|
|
2244
|
+
};
|
|
2245
|
+
var CENTER_BASES2 = {
|
|
2246
|
+
seat: "the seat",
|
|
2247
|
+
capital: "the capital",
|
|
2248
|
+
busiest_settlement: "the settlement with the most hotels",
|
|
2249
|
+
hub: "the locality's hub",
|
|
2250
|
+
point: "its location"
|
|
2251
|
+
};
|
|
2252
|
+
function radiusWords(meters, source) {
|
|
2253
|
+
return `within ${km(meters)}${source === "default" ? ", the default radius" : ""}`;
|
|
2254
|
+
}
|
|
2255
|
+
function placeHeader(d, matched) {
|
|
2256
|
+
const p = d.place;
|
|
2257
|
+
const scope = d.scope === "locality" ? "locality" : d.scope === "area" ? d.radiusMeters === null ? p.kind : `${p.kind}, within ${km(d.radiusMeters)} of its center` : d.scope === "radius" ? d.radiusMeters === null ? `${p.kind}, radius not stated` : `${p.kind}, ${radiusWords(d.radiusMeters, d.radiusSource)}` : `${p.kind}, scope ${d.scope}`;
|
|
2258
|
+
const where = [p.region && `${p.region.name} ${p.region.placeId}`, p.countryCode].filter(Boolean).join(", ");
|
|
2259
|
+
const other = [p.names.en, p.names.zh].find((n) => n && n !== p.name);
|
|
2260
|
+
return [
|
|
2261
|
+
`${p.name} (${scope})`,
|
|
2262
|
+
plural(matched, "hotel"),
|
|
2263
|
+
p.placeId,
|
|
2264
|
+
where,
|
|
2265
|
+
other ? `also named ${other}` : ""
|
|
2266
|
+
].filter(Boolean).join(" \xB7 ");
|
|
2267
|
+
}
|
|
2268
|
+
function header(r) {
|
|
2269
|
+
const d = r.destination;
|
|
2270
|
+
const matched = r.coverage.matched;
|
|
2271
|
+
let head;
|
|
2272
|
+
if (d === null) {
|
|
2273
|
+
head = `${r.text ? `Text "${r.text.value}"` : "All hotels"} \xB7 ${plural(matched, "hotel")}`;
|
|
2274
|
+
} else if (d.kind === "place") {
|
|
2275
|
+
head = placeHeader(d, matched);
|
|
2276
|
+
} else if (d.kind === "point") {
|
|
2277
|
+
const p = d;
|
|
2278
|
+
const { lat, lng } = p.center.location;
|
|
2279
|
+
head = `Point ${lat}, ${lng} (${radiusWords(p.radiusMeters, p.radiusSource)}) \xB7 ${plural(matched, "hotel")}`;
|
|
2280
|
+
} else {
|
|
2281
|
+
head = `Destination of kind ${d.kind} \xB7 ${plural(matched, "hotel")}`;
|
|
2282
|
+
}
|
|
2283
|
+
if (d !== null && r.text) head += ` \xB7 text "${r.text.value}" (exact words)`;
|
|
2284
|
+
return head;
|
|
2285
|
+
}
|
|
2286
|
+
function centerLine(d) {
|
|
2287
|
+
if (d === null || d.kind !== "place" && d.kind !== "point") return null;
|
|
2288
|
+
const c = d.center;
|
|
2289
|
+
const from = c.place ? `${c.place.name} ${c.place.placeId}` : `${c.location.lat}, ${c.location.lng}`;
|
|
2290
|
+
return `Distances from ${from} (${label(CENTER_BASES2, c.basis)}).`;
|
|
2291
|
+
}
|
|
2292
|
+
function pricingWords(r) {
|
|
2293
|
+
const { mode, scope, status } = r.pricing;
|
|
2294
|
+
if (mode === "none") return "no prices (no stay given)";
|
|
2295
|
+
if (mode === "indicative" && status === "unavailable") {
|
|
2296
|
+
return "indicative prices unavailable this time";
|
|
2297
|
+
}
|
|
2298
|
+
if (mode === "indicative") {
|
|
2299
|
+
return "indicative prices per room-night: estimates, not bookable (only offers are)";
|
|
2300
|
+
}
|
|
2301
|
+
return `prices ${[mode, scope, status].filter(Boolean).join(", ")}`;
|
|
2302
|
+
}
|
|
2303
|
+
function countsLine(r) {
|
|
2304
|
+
const c = r.coverage;
|
|
2305
|
+
const country = r.destination?.kind === "place" ? r.destination.place.countryCode : "the country";
|
|
2306
|
+
const parts = [`${num(c.matched)} matched`];
|
|
2307
|
+
if (c.priced !== null) parts.push(`${num(c.priced)} priced`);
|
|
2308
|
+
if (c.withPrice !== null) parts.push(`${num(c.withPrice)} with a price`);
|
|
2309
|
+
else if (r.pricing.mode === "indicative") parts.push("with a price: unknown");
|
|
2310
|
+
if (c.excludedNoPrice !== null) {
|
|
2311
|
+
parts.push(`${num(c.excludedNoPrice)} left out for having no indicative price`);
|
|
2312
|
+
}
|
|
2313
|
+
if (c.excludedNoFacilityData !== null) {
|
|
2314
|
+
parts.push(`${num(c.excludedNoFacilityData)} left out for listing no facilities`);
|
|
2315
|
+
}
|
|
2316
|
+
if (c.withinMaxRadius !== null) parts.push(`${num(c.withinMaxRadius)} within 80 km`);
|
|
2317
|
+
if (c.textAnywhere !== null) {
|
|
2318
|
+
parts.push(`${num(c.textAnywhere)} match the text anywhere in ${country}`);
|
|
2319
|
+
}
|
|
2320
|
+
if (c.nameAsText !== null) parts.push(`${num(c.nameAsText)} have the name as text`);
|
|
2321
|
+
return `Counts: ${parts.join(", ")}.`;
|
|
2322
|
+
}
|
|
2323
|
+
function localitiesLines(loc, areaName) {
|
|
2324
|
+
const lines = [
|
|
2325
|
+
'Localities: pass one back as destination { kind: "locality", placeId }, never the name. The number is its matching hotels; "incl." lists places inside it, with all their hotels regardless of filters ("hotels overall").'
|
|
2326
|
+
];
|
|
2327
|
+
for (const item of loc.items) {
|
|
2328
|
+
let line = `- ${item.place.name} ${item.place.placeId} ${num(item.count)}`;
|
|
2329
|
+
if (item.total !== item.count) line += ` here, ${num(item.total)} as a locality`;
|
|
2330
|
+
const sameName = areaName !== null && [item.place.name, item.place.names.en, item.place.names.zh].some(
|
|
2331
|
+
(n) => n?.toLowerCase() === areaName.toLowerCase()
|
|
2332
|
+
);
|
|
2333
|
+
if (sameName) line += ` \u2014 the locality, not the whole of ${areaName}`;
|
|
2334
|
+
if (item.includes.length > 0) {
|
|
2335
|
+
const inc = item.includes.map(
|
|
2336
|
+
(i) => `${i.place.name} ${i.place.placeId}, ${num(i.members)} hotels overall`
|
|
2337
|
+
);
|
|
2338
|
+
line += ` (incl. ${inc.join("; ")})`;
|
|
2339
|
+
}
|
|
2340
|
+
lines.push(line);
|
|
2341
|
+
}
|
|
2342
|
+
if (loc.unassigned > 0) {
|
|
2343
|
+
lines.push(`${plural(loc.unassigned, "hotel")} in this search belong to no locality.`);
|
|
2344
|
+
}
|
|
2345
|
+
if (loc.more > 0) {
|
|
2346
|
+
lines.push(`${num(loc.more)} more localities; name one with place_name.`);
|
|
2347
|
+
}
|
|
2348
|
+
return lines;
|
|
2349
|
+
}
|
|
2350
|
+
function priceWords(h, status) {
|
|
2351
|
+
const p = h.price;
|
|
2352
|
+
if (p === null) {
|
|
2353
|
+
return status === "ok" ? "no indicative price" : "price unavailable this time";
|
|
2354
|
+
}
|
|
2355
|
+
let text2 = `from ${money2(p.fromPerRoomNight)}/room-night (${money2(p.fromPerRoomStay)} per room for the stay)`;
|
|
2356
|
+
if (p.approx) {
|
|
2357
|
+
text2 += ` \u2248 ${money2(p.approx.fromPerRoomNight)}/room-night (${money2(p.approx.fromPerRoomStay)}), exchange rate of ${p.approx.rateDate}`;
|
|
2358
|
+
}
|
|
2359
|
+
if (p.kind !== "indicative" || p.basis !== "lowest_unit") {
|
|
2360
|
+
text2 += ` [${p.kind}, ${p.basis}]`;
|
|
2361
|
+
}
|
|
2362
|
+
return text2;
|
|
2363
|
+
}
|
|
2364
|
+
function hotelLine(h, i, r) {
|
|
2365
|
+
const where = [h.address.region?.name, h.address.countryCode].filter(Boolean).join(", ");
|
|
2366
|
+
return [
|
|
2367
|
+
`${i + 1}. ${named(h.name, h.names)}`,
|
|
2368
|
+
h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
|
|
2369
|
+
r.destination === null ? "" : h.distanceMeters === null ? "distance unknown" : km(h.distanceMeters),
|
|
2370
|
+
r.pricing.mode === "none" ? "" : priceWords(h, r.pricing.status),
|
|
2371
|
+
h.address.line ? h.address.line.text : "address not listed",
|
|
2372
|
+
where,
|
|
2373
|
+
h.hotelId
|
|
2374
|
+
].filter(Boolean).join(" \xB7 ");
|
|
2375
|
+
}
|
|
2376
|
+
function renderSearch(r) {
|
|
2377
|
+
const lines = [header(r)];
|
|
2378
|
+
lines.push(
|
|
2379
|
+
[
|
|
2380
|
+
r.stay ? `Stay ${stayLine(r.stay.checkIn, r.stay.checkOut)}, ${party(r.stay.rooms)}` : "No stay",
|
|
2381
|
+
pricingWords(r),
|
|
2382
|
+
`sort: ${label(SORTS, r.sort)}`
|
|
2383
|
+
].join(" \xB7 ")
|
|
2384
|
+
);
|
|
2385
|
+
const center2 = centerLine(r.destination);
|
|
2386
|
+
if (center2) lines.push(center2);
|
|
2387
|
+
lines.push(countsLine(r));
|
|
2388
|
+
const pool = r.coverage.poolRadiusMeters;
|
|
2389
|
+
if (pool !== null) {
|
|
2390
|
+
const d = r.destination;
|
|
2391
|
+
const known = d !== null && (d.kind === "place" || d.kind === "point") ? d : null;
|
|
2392
|
+
const around = known?.center.place?.name ?? (known?.kind === "place" ? known.place.name : "the center");
|
|
2393
|
+
const priced = r.coverage.priced === null ? "" : ` ${num(r.coverage.priced)}`;
|
|
2394
|
+
lines.push(
|
|
2395
|
+
`Only the nearest${priced} matches were priced: "cheapest" here means cheapest within ${km(pool)} of ${around}.`
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
if (r.coverage.localities) {
|
|
2399
|
+
const areaName = r.destination?.kind === "place" ? r.destination.place.name : null;
|
|
2400
|
+
lines.push("", ...localitiesLines(r.coverage.localities, areaName));
|
|
2401
|
+
}
|
|
2402
|
+
lines.push("");
|
|
2403
|
+
if (r.data.length === 0) lines.push("No hotels on this page.");
|
|
2404
|
+
r.data.forEach((h, i) => lines.push(hotelLine(h, i, r)));
|
|
2405
|
+
if (r.notes.length > 0) {
|
|
2406
|
+
lines.push("", "Notes:", ...r.notes.map((n) => `- ${n.code}: ${n.message}`));
|
|
2407
|
+
}
|
|
2408
|
+
lines.push(
|
|
2409
|
+
"",
|
|
2410
|
+
r.nextCursor === null ? "Last page." : `Next page: call again with only the cursor and, optionally, limit: { cursor: "${r.nextCursor}" }.`
|
|
2411
|
+
);
|
|
2412
|
+
return lines.join("\n");
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/render/errors.ts
|
|
2416
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2417
|
+
function changeLine(c) {
|
|
2418
|
+
const worse = c.worse === true ? " (worse)" : c.worse === false ? " (not worse)" : "";
|
|
2419
|
+
const pair = (show) => `${show(c.previous)} \u2192 ${show(c.current)}`;
|
|
2420
|
+
switch (c.field) {
|
|
2421
|
+
case "price":
|
|
2422
|
+
return `- price: ${pair(money2)}${worse}`;
|
|
2423
|
+
case "meals":
|
|
2424
|
+
return `- meals: ${pair(mealsText)}${worse}`;
|
|
2425
|
+
case "cancellation":
|
|
2426
|
+
return `- cancellation: ${pair(cancellationText)}${worse}`;
|
|
2427
|
+
case "confirmation":
|
|
2428
|
+
return `- confirmation: ${pair(confirmationText)}${worse}`;
|
|
2429
|
+
case "pay_at_property":
|
|
2430
|
+
return `- fees not in the total: ${pair(feesText)}${worse}`;
|
|
2431
|
+
default:
|
|
2432
|
+
return `- ${c.field}: ${raw(c.previous)} \u2192 ${raw(c.current)}${worse}`;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
function tooBroad(d) {
|
|
2436
|
+
const lines = [
|
|
2437
|
+
'Next: show the user these localities and ask which they prefer, unless their request already names one of them; then search again with destination { kind: "locality", placeId: "<id>" } and the same text, filters, stay and sort.',
|
|
2438
|
+
...localitiesLines(d.localities, null)
|
|
2439
|
+
];
|
|
2440
|
+
const top = d.localities.items[0];
|
|
2441
|
+
if (top && top.total > d.poolLimit && d.center?.place?.placeId === top.place.placeId) {
|
|
2442
|
+
lines.push(
|
|
2443
|
+
`Or: ${top.place.name}, the area's center, alone has ${num(top.total)} matching hotels: the same destination with radiusMeters (up to ${LIMITS.radiusMetersMax}) prices nearly the same hotels (the nearest ${num(d.poolLimit)} around ${top.place.name}).`
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
return lines;
|
|
2447
|
+
}
|
|
2448
|
+
function ambiguous(d) {
|
|
2449
|
+
const shown = d.candidatesTotal > d.candidates.length ? `${d.candidates.length} of ${d.candidatesTotal} shown` : `${d.candidatesTotal}`;
|
|
2450
|
+
const lines = [
|
|
2451
|
+
`Places named "${d.name}" in ${d.countryCode} (${shown}). Ask the user which one, then search with destination { kind: "place", placeId: "<id>" }:`
|
|
2452
|
+
];
|
|
2453
|
+
for (const c of d.candidates) {
|
|
2454
|
+
const other = [c.names.en, c.names.zh].find((n) => n && n !== c.name);
|
|
2455
|
+
const where = [c.region && `${c.region.name} ${c.region.placeId}`, c.countryCode].filter(Boolean).join(", ");
|
|
2456
|
+
const hotels = c.hotels.scope === "area" ? `${plural(c.hotels.count, "hotel")} in the area` : c.hotels.radiusMeters === null ? plural(c.hotels.count, "hotel") : `${plural(c.hotels.count, "hotel")} within ${km(c.hotels.radiusMeters)}`;
|
|
2457
|
+
lines.push(
|
|
2458
|
+
[
|
|
2459
|
+
`- ${c.name}${other ? ` (${other})` : ""} ${c.placeId}`,
|
|
2460
|
+
`${c.kind} in ${where}`,
|
|
2461
|
+
c.population === null ? "population not recorded" : `population ${num(c.population)}`,
|
|
2462
|
+
hotels
|
|
2463
|
+
].join(" \xB7 ")
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
if (d.nameAsText !== null) {
|
|
2467
|
+
lines.push(`${plural(d.nameAsText, "hotel")} have "${d.name}" in their name or address: it can also be searched as text.`);
|
|
2468
|
+
}
|
|
2469
|
+
return lines;
|
|
2470
|
+
}
|
|
2471
|
+
function notFound(d) {
|
|
2472
|
+
switch (d.reason) {
|
|
2473
|
+
case "no_match": {
|
|
2474
|
+
const m = d;
|
|
2475
|
+
const text2 = m.nameAsText === null ? "" : ` ${plural(m.nameAsText, "hotel")} have "${m.name}" in their name or address: it can be searched as text.`;
|
|
2476
|
+
return [`No place named "${m.name}" in ${m.countryCode}.${text2}`];
|
|
2477
|
+
}
|
|
2478
|
+
case "unknown_id":
|
|
2479
|
+
return [`Unknown placeId ${d.placeId}.`];
|
|
2480
|
+
case "not_a_locality": {
|
|
2481
|
+
const n = d;
|
|
2482
|
+
const town = `{ kind: "place", placeId: "${n.placeId}" }`;
|
|
2483
|
+
if (n.hub === null) {
|
|
2484
|
+
return [`${n.placeId} belongs to no locality: search ${town} for an 8 km circle around it.`];
|
|
2485
|
+
}
|
|
2486
|
+
const total = n.hubTotal === null ? "" : ` (${plural(n.hubTotal, "matching hotel")})`;
|
|
2487
|
+
return [
|
|
2488
|
+
`${n.placeId} is not a locality. Its hotels belong to the locality ${n.hub.name} ${n.hub.placeId}${total}.`,
|
|
2489
|
+
`Search { kind: "locality", placeId: "${n.hub.placeId}" } for those, or ${town} for an 8 km circle around the town itself.`
|
|
2490
|
+
];
|
|
2491
|
+
}
|
|
2492
|
+
default:
|
|
2493
|
+
return [`Details: ${raw(d)}`];
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
function detailLines(e) {
|
|
2497
|
+
if (!("details" in e) || e.details === void 0) return [];
|
|
2498
|
+
switch (e.code) {
|
|
2499
|
+
case "invalid_request": {
|
|
2500
|
+
const d = e.details;
|
|
2501
|
+
const lines = d.issues.map((i) => `- ${i.pointer}: ${i.message}`);
|
|
2502
|
+
if (d.filterable) lines.push(`Filterable amenities: ${d.filterable.join(", ")}.`);
|
|
2503
|
+
if (d.share) {
|
|
2504
|
+
lines.push(
|
|
2505
|
+
`${d.share.code} is listed by ${(d.share.listedShare * 100).toFixed(1)} % of hotels that list any facility.`
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
return lines;
|
|
2509
|
+
}
|
|
2510
|
+
case "cursor_invalid":
|
|
2511
|
+
return [`The cursor is ${e.details.reason}: search again without a cursor.`];
|
|
2512
|
+
case "order_removed":
|
|
2513
|
+
return [`Order ${e.details.orderId}, reference ${e.details.reference}.`];
|
|
2514
|
+
case "offer_expired":
|
|
2515
|
+
return [`Offer ${e.details.offerId} expired at ${when(e.details.expiredAt, null)}: get fresh offers.`];
|
|
2516
|
+
case "offer_changed":
|
|
2517
|
+
return [
|
|
2518
|
+
"Changes:",
|
|
2519
|
+
...e.details.changes.map(changeLine),
|
|
2520
|
+
"New offer, held for ordering:",
|
|
2521
|
+
offerLine(e.details.offer),
|
|
2522
|
+
"Show the user these changes and ask again before ordering the new offer."
|
|
2523
|
+
];
|
|
2524
|
+
case "offer_already_ordered":
|
|
2525
|
+
case "order_in_progress":
|
|
2526
|
+
case "order_attempts_exhausted":
|
|
2527
|
+
return [`Order ${e.details.orderId}.`];
|
|
2528
|
+
case "order_exists":
|
|
2529
|
+
return [`Order ${e.details.orderId}, ${label(ORDER_STATUS_WORDS, e.details.status)}.`];
|
|
2530
|
+
case "destination_ambiguous":
|
|
2531
|
+
return ambiguous(e.details);
|
|
2532
|
+
case "destination_not_found":
|
|
2533
|
+
return notFound(e.details);
|
|
2534
|
+
case "search_too_broad":
|
|
2535
|
+
return tooBroad(e.details);
|
|
2536
|
+
case "stay_unsupported":
|
|
2537
|
+
return e.details.reason === "mixed_occupancy" ? ["Every room must have the same party: ask once per party."] : e.details.reason === "max_nights" ? [`At most ${plural(e.details.max, "night")} per stay.`] : [`Details: ${raw(e.details)}`];
|
|
2538
|
+
case "stay_in_past":
|
|
2539
|
+
return [`Check-in ${e.details.checkIn} is already past${e.details.timeZone ? ` in ${e.details.timeZone}` : ""}.`];
|
|
2540
|
+
case "offer_unavailable":
|
|
2541
|
+
return [
|
|
2542
|
+
`Offer ${e.details.offerId}: ${label({ sold_out: "sold out", refused: "can no longer be booked" }, e.details.reason)}. Get fresh offers.`
|
|
2543
|
+
];
|
|
2544
|
+
case "rate_limited":
|
|
2545
|
+
return [`Limit reached for: ${e.details.scope}.`];
|
|
2546
|
+
case "supplier_unavailable":
|
|
2547
|
+
return [
|
|
2548
|
+
label(
|
|
2549
|
+
{
|
|
2550
|
+
not_started: "Nothing was sent to the hotel's supplier.",
|
|
2551
|
+
unknown: "The request was sent and timed out: its outcome is unknown."
|
|
2552
|
+
},
|
|
2553
|
+
e.details.outcome
|
|
2554
|
+
)
|
|
2555
|
+
];
|
|
2556
|
+
default:
|
|
2557
|
+
return [`Details: ${raw(e.details)}`];
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function retryLine(retryable, after) {
|
|
2561
|
+
if (retryable === true) {
|
|
2562
|
+
return typeof after === "number" ? `Retryable: retry after ${after} s.` : "Retryable.";
|
|
2563
|
+
}
|
|
2564
|
+
return retryable === false ? "Not retryable." : "Retryable: unknown.";
|
|
2565
|
+
}
|
|
2566
|
+
function renderError(envelope, status) {
|
|
2567
|
+
const http = status === void 0 ? "" : ` (HTTP ${status})`;
|
|
2568
|
+
let known = null;
|
|
2569
|
+
let body = null;
|
|
2570
|
+
try {
|
|
2571
|
+
const parsed = parseOpen(errorEnvelope, envelope).error;
|
|
2572
|
+
body = parsed;
|
|
2573
|
+
if (Object.hasOwn(ERROR_CODES, parsed.code)) known = parsed;
|
|
2574
|
+
} catch {
|
|
2575
|
+
if (isObject(envelope) && isObject(envelope.error)) body = envelope.error;
|
|
2576
|
+
}
|
|
2577
|
+
if (body === null) {
|
|
2578
|
+
return `Error${http}: the answer carried no readable error.
|
|
2579
|
+
Retryable: unknown.`;
|
|
2580
|
+
}
|
|
2581
|
+
const code = typeof body.code === "string" ? body.code : "unknown";
|
|
2582
|
+
const lines = [
|
|
2583
|
+
`Error ${code}${http}${typeof body.message === "string" ? `: ${body.message}` : ""}`,
|
|
2584
|
+
retryLine(body.retryable, body.retryAfterSeconds)
|
|
2585
|
+
];
|
|
2586
|
+
if (typeof body.param === "string") lines.push(`Field: ${body.param}`);
|
|
2587
|
+
if (known) lines.push(...detailLines(known));
|
|
2588
|
+
else if (body.details !== void 0) lines.push(`Details: ${raw(body.details)}`);
|
|
2589
|
+
if (typeof body.requestId === "string") lines.push(`Request id: ${body.requestId}`);
|
|
2590
|
+
return lines.join("\n");
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/render/index.ts
|
|
2594
|
+
function renderToolResult(tool, data) {
|
|
2595
|
+
const schema = TOOLS.find((t) => t.name === tool)?.output;
|
|
2596
|
+
let parsed;
|
|
2597
|
+
try {
|
|
2598
|
+
if (!schema) throw new Error(`no output schema for ${tool}`);
|
|
2599
|
+
parsed = parseOpen(schema, data);
|
|
2600
|
+
} catch {
|
|
2601
|
+
const text2 = `This ${tool} result is not in the expected shape; as JSON:
|
|
2602
|
+
${raw(data)}`;
|
|
2603
|
+
return ORDER_TOOLS.has(tool) && holdsPaymentUrl(data) ? `${text2}
|
|
2604
|
+
|
|
2605
|
+
${ORDER_NEXT_STEP}` : text2;
|
|
2606
|
+
}
|
|
2607
|
+
switch (tool) {
|
|
2608
|
+
case "search_hotels":
|
|
2609
|
+
return renderSearch(parsed);
|
|
2610
|
+
case "get_hotel":
|
|
2611
|
+
return renderHotel(parsed);
|
|
2612
|
+
case "get_hotel_offers":
|
|
2613
|
+
return renderOffers(parsed);
|
|
2614
|
+
case "book_hotel":
|
|
2615
|
+
case "get_order": {
|
|
2616
|
+
const order2 = parsed;
|
|
2617
|
+
return withConsent(renderOrder(order2), [order2]);
|
|
2618
|
+
}
|
|
2619
|
+
case "list_orders": {
|
|
2620
|
+
const list = parsed;
|
|
2621
|
+
return withConsent(renderOrderList(list), list.data);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
var ORDER_TOOLS = /* @__PURE__ */ new Set(["book_hotel", "get_order", "list_orders"]);
|
|
2626
|
+
function holdsPaymentUrl(value, seen = /* @__PURE__ */ new Set()) {
|
|
2627
|
+
if (typeof value !== "object" || value === null || seen.has(value)) return false;
|
|
2628
|
+
seen.add(value);
|
|
2629
|
+
const payment = value.payment;
|
|
2630
|
+
if (typeof payment === "object" && payment !== null && typeof payment.url === "string" && payment.url !== "") {
|
|
2631
|
+
return true;
|
|
2632
|
+
}
|
|
2633
|
+
return Object.values(value).some((v) => holdsPaymentUrl(v, seen));
|
|
2634
|
+
}
|
|
2635
|
+
function withConsent(text2, orders) {
|
|
2636
|
+
return orders.some((o) => o.payment.url !== null) ? `${text2}
|
|
2637
|
+
|
|
2638
|
+
${ORDER_NEXT_STEP}` : text2;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// src/conventions/unknown-is-not-no.ts
|
|
2642
|
+
var RESULT_LIST = "A result list: the API built it, so an empty list means none, not unknown";
|
|
2643
|
+
var REQUEST_ECHO = "An echo of the request: it says what the caller sent";
|
|
2644
|
+
var API_DECIDES = "A flag the API itself decides, so it is never unknown";
|
|
2645
|
+
var THE_FLAG = "The flag that says whether the source stated its siblings";
|
|
2646
|
+
var UNKNOWN_IS_NOT_NO_EXEMPTIONS = {
|
|
2647
|
+
data: `${RESULT_LIST} (a page of hotels or orders).`,
|
|
2648
|
+
notes: `${RESULT_LIST} (the notes the search added).`,
|
|
2649
|
+
offers: `${RESULT_LIST} (the offers left after selection).`,
|
|
2650
|
+
items: `${RESULT_LIST} (an order's items, a listed set's entries).`,
|
|
2651
|
+
issues: `${RESULT_LIST} (what was wrong with the request).`,
|
|
2652
|
+
changes: `${RESULT_LIST} (every change between the offer and the verify).`,
|
|
2653
|
+
hotels: `${RESULT_LIST} (the hotels that have offers).`,
|
|
2654
|
+
unavailable: `${RESULT_LIST} (the hotels without offers, each with a reason).`,
|
|
2655
|
+
candidates: `${RESULT_LIST} (the places a name could mean).`,
|
|
2656
|
+
"localities.items": `${RESULT_LIST} (the top locality hubs of the scope).`,
|
|
2657
|
+
"localities.items.includes": `${RESULT_LIST} (other places among a hub's members).`,
|
|
2658
|
+
filterable: `${RESULT_LIST} (the amenity codes a search can filter by).`,
|
|
2659
|
+
rooms: `${REQUEST_ECHO} (the rooms of the stay).`,
|
|
2660
|
+
childrenAges: `${REQUEST_ECHO} (each child's age; empty means no children).`,
|
|
2661
|
+
retryable: `${API_DECIDES} (whether retrying can help).`,
|
|
2662
|
+
mandatory: `${API_DECIDES} (whether a fee is charged whatever the guest chooses).`,
|
|
2663
|
+
worse: `${API_DECIDES} (whether a change is worse for the guest).`,
|
|
2664
|
+
requiresSignIn: `${API_DECIDES} (whether the payment link needs a website sign-in).`,
|
|
2665
|
+
localCurrencyAtCheckout: `${API_DECIDES} (whether checkout may charge in the card's currency).`,
|
|
2666
|
+
bookable: `${API_DECIDES} (whether the hotel is still in the index).`,
|
|
2667
|
+
cityLike: `${API_DECIDES} (frozen per gazetteer release; null where not computed).`,
|
|
2668
|
+
known: `${THE_FLAG} (meals).`,
|
|
2669
|
+
listed: `${THE_FLAG} (policies, amenities, rooms).`
|
|
2670
|
+
};
|
|
2671
|
+
|
|
989
2672
|
// package.json
|
|
990
2673
|
var package_default = {
|
|
991
2674
|
name: "@fortrip/agent-contract",
|
|
992
|
-
version: "
|
|
2675
|
+
version: "3.0.0",
|
|
993
2676
|
license: "Apache-2.0",
|
|
994
|
-
description: "The contract of Fortrip's agent API (
|
|
2677
|
+
description: "The contract of Fortrip's agent API (https://api.fortrip.ai/v1) and its MCP server: limits, paths, request and response schemas, error codes and field rules.",
|
|
995
2678
|
type: "module",
|
|
996
2679
|
repository: {
|
|
997
2680
|
type: "git",
|
|
@@ -1038,47 +2721,104 @@ var package_default = {
|
|
|
1038
2721
|
// src/version.ts
|
|
1039
2722
|
var CONTRACT_VERSION = package_default.version;
|
|
1040
2723
|
export {
|
|
2724
|
+
AMENITY_CODES,
|
|
1041
2725
|
API,
|
|
1042
|
-
BOARD_CODES,
|
|
1043
|
-
BOOKING_NEXT_STEP,
|
|
1044
2726
|
CARD_NUMBER_MESSAGE,
|
|
2727
|
+
CENTER_BASES,
|
|
1045
2728
|
COMMANDS,
|
|
1046
2729
|
COMMAND_GROUPS,
|
|
1047
2730
|
CONTRACT_VERSION,
|
|
1048
|
-
|
|
1049
|
-
|
|
2731
|
+
DISPLAY_CURRENCIES,
|
|
2732
|
+
ERROR_CODES,
|
|
2733
|
+
ERROR_DETAILS,
|
|
2734
|
+
FILTERABLE_AMENITIES,
|
|
1050
2735
|
GLOBAL_FLAGS,
|
|
1051
2736
|
GUEST_NAME_MESSAGE,
|
|
1052
2737
|
GUEST_NAME_PATTERN,
|
|
1053
2738
|
GUEST_NAME_TITLE,
|
|
1054
2739
|
LIMITS,
|
|
1055
|
-
|
|
1056
|
-
|
|
2740
|
+
MCP_OUTPUT,
|
|
2741
|
+
MCP_PATH,
|
|
2742
|
+
ORDER_NEXT_STEP,
|
|
2743
|
+
ORDER_STATUSES,
|
|
2744
|
+
PAYMENT_STATUSES,
|
|
2745
|
+
PLACE_KINDS,
|
|
2746
|
+
POLICY_TOPICS,
|
|
2747
|
+
PUBLIC_ORIGINS,
|
|
2748
|
+
SEARCH_NOTE_CODES,
|
|
1057
2749
|
SEARCH_SORTS,
|
|
1058
2750
|
SUPPORT_EMAIL,
|
|
1059
2751
|
TOOLS,
|
|
2752
|
+
UNAVAILABLE_REASONS,
|
|
2753
|
+
UNKNOWN_IS_NOT_NO_EXEMPTIONS,
|
|
1060
2754
|
addIsoDateDays,
|
|
1061
2755
|
authConfig,
|
|
1062
|
-
|
|
1063
|
-
|
|
2756
|
+
cancellation,
|
|
2757
|
+
center,
|
|
1064
2758
|
commandInvocation,
|
|
2759
|
+
confirmation,
|
|
1065
2760
|
containsCardNumber,
|
|
1066
2761
|
countIsoDateDays,
|
|
1067
|
-
|
|
2762
|
+
createOrderInput,
|
|
2763
|
+
createSearchHotelsInput,
|
|
2764
|
+
destination,
|
|
2765
|
+
errorEnvelope,
|
|
1068
2766
|
flag,
|
|
2767
|
+
formatMoney,
|
|
1069
2768
|
getHotelInput,
|
|
1070
|
-
|
|
2769
|
+
getOrderInput,
|
|
1071
2770
|
guestNameRe,
|
|
2771
|
+
hotel,
|
|
2772
|
+
hotelId,
|
|
2773
|
+
hotelOffersInput,
|
|
2774
|
+
hotelOffersResult,
|
|
2775
|
+
hotelSummary,
|
|
2776
|
+
indicativePrice,
|
|
2777
|
+
instant,
|
|
1072
2778
|
isIsoDate,
|
|
1073
|
-
|
|
2779
|
+
knownLocaleText,
|
|
2780
|
+
latLng,
|
|
2781
|
+
listOrdersInput,
|
|
2782
|
+
localDate,
|
|
2783
|
+
localTime,
|
|
2784
|
+
localities,
|
|
2785
|
+
localizedText,
|
|
1074
2786
|
meResult,
|
|
2787
|
+
meals,
|
|
2788
|
+
money,
|
|
2789
|
+
names,
|
|
1075
2790
|
nonEmpty,
|
|
1076
2791
|
normalizeGuestName,
|
|
2792
|
+
offer,
|
|
2793
|
+
offerChange,
|
|
2794
|
+
offerId,
|
|
2795
|
+
omitted,
|
|
1077
2796
|
oneOf,
|
|
1078
|
-
|
|
2797
|
+
openEnum,
|
|
2798
|
+
openObject,
|
|
2799
|
+
openUnion,
|
|
2800
|
+
order,
|
|
2801
|
+
orderId,
|
|
2802
|
+
orderList,
|
|
2803
|
+
orderReference,
|
|
2804
|
+
parseOpen,
|
|
2805
|
+
payAtProperty,
|
|
2806
|
+
place,
|
|
2807
|
+
placeId,
|
|
2808
|
+
placeRef,
|
|
2809
|
+
renderError,
|
|
1079
2810
|
renderGuide,
|
|
2811
|
+
renderToolResult,
|
|
2812
|
+
roomFacts,
|
|
1080
2813
|
searchHotelsInput,
|
|
2814
|
+
searchResult,
|
|
2815
|
+
stay,
|
|
2816
|
+
stayEcho,
|
|
2817
|
+
strictErrorEnvelope,
|
|
1081
2818
|
strictInput,
|
|
2819
|
+
strictOutput,
|
|
1082
2820
|
text,
|
|
2821
|
+
timeZone,
|
|
2822
|
+
usdMoney,
|
|
1083
2823
|
wholeNumber
|
|
1084
2824
|
};
|