@curless/hotel-storefront 0.47.2 → 0.47.4

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/dist/tools.js CHANGED
@@ -8,7 +8,7 @@ import { namedExactly, roomNamesOf, withTraditional } from './names.js';
8
8
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
9
9
  import { regionOf } from './regions.js';
10
10
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
11
- import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, shownOf, tripKey, } from './trip.js';
11
+ import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, pickedKey, shownOf, tripKey, } from './trip.js';
12
12
  import { DEMO_NOTE, branded, catalogue, noteFor, upFirst } from './vocab.js';
13
13
  // The eleven things a guest can do: find hotels, look at one on dates, see what
14
14
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
@@ -182,7 +182,7 @@ const forCapabilities = (tools, n, v, caps) => {
182
182
  },
183
183
  }),
184
184
  };
185
- if (t.name === n.addToTrip)
185
+ if (t.name === n.addToTrip || t.name === n.pickRoom)
186
186
  out = {
187
187
  ...out,
188
188
  inputSchema: withProps(out.inputSchema, {
@@ -194,7 +194,10 @@ const forCapabilities = (tools, n, v, caps) => {
194
194
  }),
195
195
  };
196
196
  }
197
- if (t.name === n.requestBooking) {
197
+ // ⚠️ THE PICKED ROOM'S BOOKING TAKES THE SAME GUEST FIELDS as the trip's:
198
+ // a phone where the hotel needs one, and no `rememberRequests` without
199
+ // members. Its description is its own (below, only request_booking's).
200
+ if (t.name === n.requestBooking || t.name === n.bookPicked) {
198
201
  if (caps.requiresPhone) {
199
202
  const schema = out.inputSchema;
200
203
  out = {
@@ -223,7 +226,7 @@ const forCapabilities = (tools, n, v, caps) => {
223
226
  if (!caps.members)
224
227
  out = { ...out, inputSchema: without(out.inputSchema, 'rememberRequests') };
225
228
  // A market's suppliers book differently - its own description says so.
226
- if (caps.booking === 'pay-at-hotel' && !v.market) {
229
+ if (t.name === n.requestBooking && caps.booking === 'pay-at-hotel' && !v.market) {
227
230
  out = {
228
231
  ...out,
229
232
  // ⚠️ A SANDBOX BOOKS NOTHING REAL, and its description says so
@@ -257,6 +260,33 @@ const allTools = (n, v) => {
257
260
  description: 'People in the room. Default 2',
258
261
  },
259
262
  };
263
+ // The guest and what they ask of the desk: the fields of a booking, whether
264
+ // the rooms come from the trip (request_booking) or from the picked room.
265
+ const guestProps = {
266
+ name: {
267
+ type: 'string',
268
+ description: "The guest's full name, as it should appear on the booking",
269
+ },
270
+ email: { type: 'string', description: 'The email the confirmation goes to' },
271
+ phone: { type: 'string', description: 'Optional' },
272
+ requests: {
273
+ type: 'array',
274
+ items: { type: 'string', enum: REQUEST_KINDS },
275
+ description: 'Preferences the desk tries to meet on the day. None of them is held; the answer says so',
276
+ },
277
+ arrivingAt: {
278
+ type: 'string',
279
+ description: "What time the guest reaches the hotel, `HH:MM` on the hotel's own clock. A time counts as a late arrival on its own",
280
+ },
281
+ notes: {
282
+ type: 'string',
283
+ description: `Anything else for the desk, in the guest's own words, up to ${REQUEST_NOTE_MAX} characters`,
284
+ },
285
+ rememberRequests: {
286
+ type: 'boolean',
287
+ description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet or connecting room; an accessible room is asked for on each stay and is not kept',
288
+ },
289
+ };
260
290
  return [
261
291
  {
262
292
  name: n.listHotels,
@@ -499,29 +529,7 @@ const allTools = (n, v) => {
499
529
  inputSchema: {
500
530
  type: 'object',
501
531
  properties: {
502
- name: {
503
- type: 'string',
504
- description: "The guest's full name, as it should appear on the booking",
505
- },
506
- email: { type: 'string', description: 'The email the confirmation goes to' },
507
- phone: { type: 'string', description: 'Optional' },
508
- requests: {
509
- type: 'array',
510
- items: { type: 'string', enum: REQUEST_KINDS },
511
- description: 'Preferences the desk tries to meet on the day. None of them is held; the answer says so',
512
- },
513
- arrivingAt: {
514
- type: 'string',
515
- description: "What time the guest reaches the hotel, `HH:MM` on the hotel's own clock. A time counts as a late arrival on its own",
516
- },
517
- notes: {
518
- type: 'string',
519
- description: `Anything else for the desk, in the guest's own words, up to ${REQUEST_NOTE_MAX} characters`,
520
- },
521
- rememberRequests: {
522
- type: 'boolean',
523
- description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet or connecting room; an accessible room is asked for on each stay and is not kept',
524
- },
532
+ ...guestProps,
525
533
  // A booking is made with one group at a time; a trip holding two
526
534
  // groups' hotels is booked group by group (Marketplace, §6-1).
527
535
  ...(v.market
@@ -853,6 +861,47 @@ const allTools = (n, v) => {
853
861
  required: ['booking', 'email'],
854
862
  },
855
863
  },
864
+ {
865
+ // ⚠️ THE CARD'S, NEVER THE MODEL'S (`ui.visibility: ['app']`, server.ts):
866
+ // the press on Reserve. The picked room is a slot of its own beside the
867
+ // trip (`pickedKey`) - this never reads or writes the trip.
868
+ name: n.pickRoom,
869
+ description: "The card's own step when the guest presses Reserve on one rate: the one room being booked, replacing whatever was picked before. It does not touch the trip, which holds the stays an assistant arranges.",
870
+ annotations: edits(`Pick a ${v.brand} room`, open),
871
+ inputSchema: {
872
+ type: 'object',
873
+ properties: {
874
+ hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
875
+ room: {
876
+ type: 'string',
877
+ description: 'The room type id or name, as the hotel returned it',
878
+ },
879
+ ...stayProps,
880
+ rooms: {
881
+ type: 'integer',
882
+ minimum: 0,
883
+ maximum: MAX_ROOMS_PER_LINE,
884
+ description: `How many of that room type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1; 0 empties the pick`,
885
+ },
886
+ },
887
+ required: ['hotel', 'room'],
888
+ },
889
+ },
890
+ {
891
+ // ⚠️ THE CARD'S, NEVER THE MODEL'S - the booking form's submit for the
892
+ // picked room. The same booking as request_booking's; only where the
893
+ // room comes from differs (`bookTripLines`).
894
+ name: n.bookPicked,
895
+ description: "The card's own step when the guest submits the booking form for the room they picked with Reserve: books that one room for the named guest, the way a trip's rooms are booked. Leaves the trip as it is.",
896
+ annotations: books(v.market ? `Book the picked room on ${v.brand}` : `Book the picked ${v.brand} room`, open),
897
+ inputSchema: {
898
+ type: 'object',
899
+ properties: {
900
+ ...guestProps,
901
+ },
902
+ required: ['name', 'email'],
903
+ },
904
+ },
856
905
  ];
857
906
  };
858
907
  // The shape of each tool's `structuredContent`, so a reader knows what it is
@@ -1082,6 +1131,43 @@ const HOTEL_PAGE = {
1082
1131
  */
1083
1132
  soldOut: ARR,
1084
1133
  };
1134
+ /**
1135
+ * What request_booking answers - and book_picked_room, which books the same way
1136
+ * from the picked room (`direct`, below).
1137
+ */
1138
+ const BOOKING_SCHEMA = {
1139
+ type: 'object',
1140
+ properties: {
1141
+ // `trip`: nothing was booked and the trip is shown again - its price
1142
+ // moved, or (in a market) it holds two groups' stays.
1143
+ view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error', 'trip'] },
1144
+ brand: STR,
1145
+ toolNames: OBJ,
1146
+ bookingMode: STR,
1147
+ demo: BOOL,
1148
+ demoNote: STR,
1149
+ booking: OBJ,
1150
+ /** Always false here: this connector holds rooms and takes no money. */
1151
+ paid: BOOL,
1152
+ holdUntil: STR,
1153
+ total: STR,
1154
+ /** Each preference written out as what it is - something asked for, not held. */
1155
+ requested: ARR,
1156
+ /** An accessible room was asked for, which this booking does not reserve. */
1157
+ accessibility: BOOL,
1158
+ /** One per stay, when the trip held several. */
1159
+ bookings: ARR,
1160
+ /** What all of `bookings` come to, where they are in one currency (the card's first screen). */
1161
+ totalAll: STR,
1162
+ /**
1163
+ * The stays that could not be booked when the others were - each with
1164
+ * why, still in the trip, and what could be booked instead (robin,
1165
+ * 2026-10-14).
1166
+ */
1167
+ failed: ARR,
1168
+ },
1169
+ required: ['view'],
1170
+ };
1085
1171
  export const buildOutputSchemas = (n) => ({
1086
1172
  [n.listHotels]: {
1087
1173
  type: 'object',
@@ -1176,38 +1262,16 @@ export const buildOutputSchemas = (n) => ({
1176
1262
  [n.addToTrip]: TRIP_SCHEMA,
1177
1263
  [n.viewTrip]: TRIP_SCHEMA,
1178
1264
  [n.clearTrip]: TRIP_SCHEMA,
1179
- [n.requestBooking]: {
1180
- type: 'object',
1181
- properties: {
1182
- // `trip`: nothing was booked and the trip is shown again - its price
1183
- // moved, or (in a market) it holds two groups' stays.
1184
- view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error', 'trip'] },
1185
- brand: STR,
1186
- toolNames: OBJ,
1187
- bookingMode: STR,
1188
- demo: BOOL,
1189
- demoNote: STR,
1190
- booking: OBJ,
1191
- /** Always false here: this connector holds rooms and takes no money. */
1192
- paid: BOOL,
1193
- holdUntil: STR,
1194
- total: STR,
1195
- /** Each preference written out as what it is - something asked for, not held. */
1196
- requested: ARR,
1197
- /** An accessible room was asked for, which this booking does not reserve. */
1198
- accessibility: BOOL,
1199
- /** One per stay, when the trip held several. */
1200
- bookings: ARR,
1201
- /** What all of `bookings` come to, where they are in one currency (the card's first screen). */
1202
- totalAll: STR,
1203
- /**
1204
- * The stays that could not be booked when the others were - each with
1205
- * why, still in the trip, and what could be booked instead (robin,
1206
- * 2026-10-14).
1207
- */
1208
- failed: ARR,
1209
- },
1210
- required: ['view'],
1265
+ [n.requestBooking]: BOOKING_SCHEMA,
1266
+ // The card's own steps for ONE picked room (`pickedKey`): the same answers as
1267
+ // the trip's, each saying `direct` - the pick, not the trip.
1268
+ [n.pickRoom]: {
1269
+ ...TRIP_SCHEMA,
1270
+ properties: { ...TRIP_SCHEMA.properties, direct: BOOL },
1271
+ },
1272
+ [n.bookPicked]: {
1273
+ ...BOOKING_SCHEMA,
1274
+ properties: { ...BOOKING_SCHEMA.properties, direct: BOOL },
1211
1275
  },
1212
1276
  [n.listBookings]: {
1213
1277
  type: 'object',
@@ -2391,7 +2455,10 @@ const countProblem = (t, name, args) => {
2391
2455
  };
2392
2456
  // 0 means something here: take the line out of the trip, or - on a change -
2393
2457
  // the backend's own "a booking keeps at least one room; cancel it instead".
2394
- const removes = name === t.addToTrip || name === t.previewChange || name === t.changeBooking;
2458
+ const removes = name === t.addToTrip ||
2459
+ name === t.pickRoom ||
2460
+ name === t.previewChange ||
2461
+ name === t.changeBooking;
2395
2462
  return (check('guests', 1, MAX_GUESTS) ??
2396
2463
  check('rooms', removes ? 0 : 1, MAX_ROOMS_PER_LINE) ??
2397
2464
  check('quantity', 0, MAX_EXTRA_QUANTITY));
@@ -2503,14 +2570,19 @@ const needsRoomIn = (t, lines, parts) => {
2503
2570
  .join('\n'),
2504
2571
  };
2505
2572
  };
2506
- const currentTrip = async (deps, key, who) => {
2507
- const lines = deps.trips.read(key);
2573
+ /**
2574
+ * The lines in one slot of the trip store, priced - the trip itself, or (with
2575
+ * `store`) the picked room's slot. `key` stays the conversation's trip key: it
2576
+ * is the one `common` reads, so a header's trip count never counts a pick.
2577
+ */
2578
+ const currentTrip = async (deps, key, who, store = key) => {
2579
+ const lines = deps.trips.read(store);
2508
2580
  const parts = await quoteParts(deps, lines, who);
2509
2581
  // ⚠️ WHAT THE GUEST IS SHOWN IS WHAT IS BOOKED - see `ShownQuote`. Every
2510
2582
  // priced trip answer goes through here: add_to_trip, view_trip, and the
2511
2583
  // card's own trip view.
2512
2584
  if (lines.length)
2513
- deps.trips.show(key, shownOf(parts));
2585
+ deps.trips.show(store, shownOf(parts));
2514
2586
  const [only] = parts;
2515
2587
  const orphans = needsRoomIn(deps.tools, lines, parts);
2516
2588
  const needs = orphans.needsRoom.length ? { needsRoom: orphans.needsRoom } : {};
@@ -2853,6 +2925,497 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
2853
2925
  const fitsManyOf = (deps, dropped) => dropped.flatMap((d) => d.candidates?.length
2854
2926
  ? [`"${d.name}" ${namesMoreThanOne(d.candidates, branded(deps.vocab, deps.vocab.noun))}`]
2855
2927
  : []);
2928
+ /** The hotel a call named - or the answer that says there is none, or several by that name. */
2929
+ const hotelNamed = async (deps, key, wanted) => {
2930
+ const detail = await deps.backend.getHotel(wanted);
2931
+ if (detail)
2932
+ return { detail };
2933
+ const candidates = candidatesFor(deps.backend, wanted);
2934
+ return {
2935
+ answer: {
2936
+ structured: {
2937
+ view: 'error',
2938
+ ...common(deps, key),
2939
+ error: 'hotel-not-found',
2940
+ query: wanted,
2941
+ ...(candidates.length ? { candidates } : {}),
2942
+ },
2943
+ text: candidates.length
2944
+ ? `"${wanted}" ${namesMoreThanOne(candidates, branded(deps.vocab, deps.vocab.noun))}`
2945
+ : `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
2946
+ },
2947
+ };
2948
+ };
2949
+ /** An answer of the card's own pick: it says so, so the card knows it never touched the trip. */
2950
+ const direct = (a) => ({
2951
+ ...a,
2952
+ structured: { ...a.structured, direct: true },
2953
+ });
2954
+ /**
2955
+ * A room line, validated and priced against the hotel's calendar and put into
2956
+ * one slot of the trip store - THE ONE ROOM-LINE CODE, for `add_to_trip` (`store`
2957
+ * the trip's own key) and for the card's pick (`pick_room`, `store` its
2958
+ * `pickedKey`). `key` is the conversation's trip key and keeps everything else:
2959
+ * the member the price is worked out for, and every answer's `common`.
2960
+ */
2961
+ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
2962
+ const hotelId = detail.hotel.id;
2963
+ const roomRef = str(args.room);
2964
+ if (!roomRef) {
2965
+ return {
2966
+ structured: { view: 'error', ...common(deps, key), error: 'no-room-named' },
2967
+ text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
2968
+ };
2969
+ }
2970
+ // A room by the hour: today, from the start the guest gave - checked here,
2971
+ // against the starts the room takes, and never moved to fit (总线程,
2972
+ // 2026-10-12).
2973
+ const who = identity(deps, ctx, key);
2974
+ const startTime = str(args.startTime);
2975
+ const today = (startTime || !asStay(args)
2976
+ ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
2977
+ : undefined);
2978
+ const byTheHour = async () => {
2979
+ const sold = ((await today)?.rooms ?? []).filter((r) => r.hourly);
2980
+ return sold.find((r) => r.id === roomRef) ?? namedExactly(sold, roomRef);
2981
+ };
2982
+ if (startTime && deps.backend.hourlyRooms) {
2983
+ const hourlyRoom = await byTheHour();
2984
+ if (!hourlyRoom?.hourly) {
2985
+ const sold = ((await today)?.rooms ?? []).filter((r) => r.hourly);
2986
+ return {
2987
+ structured: {
2988
+ view: 'error',
2989
+ ...common(deps, key),
2990
+ error: 'no-such-room',
2991
+ query: roomRef,
2992
+ rooms: sold.map((r) => ({
2993
+ id: r.id,
2994
+ name: r.name,
2995
+ ...(r.names ? { names: r.names } : {}),
2996
+ })),
2997
+ },
2998
+ text: sold.length
2999
+ ? `${detail.hotel.name} has no room by the hour by that name today. It has: ${sold.map((r) => `${r.name} (\`${r.id}\`, ${windowWords(r.hourly)})`).join('; ')}.`
3000
+ : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3001
+ };
3002
+ }
3003
+ const bad = startProblem(hourlyRoom.hourly, startTime);
3004
+ if (bad) {
3005
+ return {
3006
+ structured: {
3007
+ view: 'error',
3008
+ ...common(deps, key),
3009
+ error: 'start-time',
3010
+ query: startTime,
3011
+ // The starts it does take. Not `hourly`: that is the flag above.
3012
+ window: hourlyRoom.hourly,
3013
+ },
3014
+ text: bad,
3015
+ };
3016
+ }
3017
+ deps.trips.set(store, {
3018
+ kind: 'room',
3019
+ hotelId,
3020
+ roomId: hourlyRoom.id,
3021
+ stay: {
3022
+ checkIn: hourlyRoom.hourly.date,
3023
+ checkOut: hourlyRoom.hourly.date,
3024
+ guests: guestsOf(args),
3025
+ },
3026
+ rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3027
+ hourly: {
3028
+ start: clockOf(minutesOf(startTime)),
3029
+ hours: hourlyRoom.hourly.hours,
3030
+ },
3031
+ });
3032
+ return currentTrip(deps, key, who, store);
3033
+ }
3034
+ const stay = asStay(args);
3035
+ // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
3036
+ // connector books someone the wrong nights, and the guest finds out at the
3037
+ // desk. The trip stays as it was and the answer asks.
3038
+ if (!stay) {
3039
+ const hourlyRoom = await byTheHour();
3040
+ return {
3041
+ structured: {
3042
+ view: 'needs-dates',
3043
+ ...common(deps, key),
3044
+ hotel: detail.hotel,
3045
+ room: roomRef,
3046
+ ...(hourlyRoom?.hourly ? { window: hourlyRoom.hourly } : {}),
3047
+ },
3048
+ text: hourlyRoom?.hourly
3049
+ ? `That is a room by the hour, sold for today: it takes \`startTime\` - ${windowWords(hourlyRoom.hourly)}.`
3050
+ : 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
3051
+ };
3052
+ }
3053
+ // ⚠️ TAKING A ROOM OUT NEEDS NO ROOM FOR SALE. The lookup below is of
3054
+ // what is for sale, so a room that had gone since it went into the trip -
3055
+ // the stay a booking could not make - could not be taken out of it
3056
+ // (2026-10-14). The line in the trip is the one asked about.
3057
+ if (args.rooms !== undefined && clampCount(args.rooms, MAX_ROOMS_PER_LINE) === 0) {
3058
+ const held = deps.trips
3059
+ .read(store)
3060
+ .find((l) => l.kind === 'room' &&
3061
+ l.hotelId === hotelId &&
3062
+ l.roomId === roomRef &&
3063
+ l.stay.checkIn === stay.checkIn &&
3064
+ l.stay.checkOut === stay.checkOut);
3065
+ if (held) {
3066
+ deps.trips.set(store, unset(held));
3067
+ return currentTrip(deps, key, identity(deps, ctx, key), store);
3068
+ }
3069
+ }
3070
+ // The backend owns the calendar - it knows each hotel's own timezone, and a
3071
+ // second opinion computed from this process's clock would disagree with it
3072
+ // for eight hours a day.
3073
+ const dated = await deps.backend.getHotel(hotelId, stay, identity(deps, ctx, key));
3074
+ if (!dated?.rooms) {
3075
+ return {
3076
+ structured: {
3077
+ view: 'needs-dates',
3078
+ ...common(deps, key),
3079
+ hotel: detail.hotel,
3080
+ room: roomRef,
3081
+ why: dated?.noRooms,
3082
+ ...(dated?.noRoomsCode ? { whyCode: dated.noRoomsCode } : {}),
3083
+ ...(dated?.noRoomsArgs ? { whyArgs: dated.noRoomsArgs } : {}),
3084
+ },
3085
+ text: dated?.noRooms ?? 'Those nights cannot be booked here.',
3086
+ };
3087
+ }
3088
+ // ⚠️ BY THE OFFER'S WHOLE NAME, in any language it has: a guest who read
3089
+ // 「豪华大床房 · 双早 · 不可取消」 on the card says that, and it is the same offer
3090
+ // as "Deluxe King Room · Breakfast for 2 · Non-refundable". Never a part of
3091
+ // it, and never a room type's bare name when it has several rates.
3092
+ const room = dated.rooms.find((r) => r.id === roomRef) ?? namedExactly(dated.rooms, roomRef);
3093
+ if (!room) {
3094
+ return {
3095
+ structured: {
3096
+ view: 'error',
3097
+ ...common(deps, key),
3098
+ error: 'no-such-room',
3099
+ query: roomRef,
3100
+ rooms: dated.rooms.map((r) => ({
3101
+ id: r.id,
3102
+ name: r.name,
3103
+ ...(r.names ? { names: r.names } : {}),
3104
+ })),
3105
+ // The hotel and the nights, so the card can take the guest straight
3106
+ // back to its rooms rather than to the start (round-2 jury).
3107
+ hotel: { id: detail.hotel.id, name: detail.hotel.name },
3108
+ stay,
3109
+ },
3110
+ text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => (/\p{Script=Han}/u.test(roomRef) && r.names?.zh ? `${r.name} (${r.names.zh})` : r.name)).join(', ')}.`,
3111
+ };
3112
+ }
3113
+ deps.trips.set(store, {
3114
+ kind: 'room',
3115
+ hotelId,
3116
+ roomId: room.id,
3117
+ stay,
3118
+ rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3119
+ });
3120
+ return currentTrip(deps, key, identity(deps, ctx, key), store);
3121
+ };
3122
+ /**
3123
+ * Books the rooms in one slot of the trip store for a named guest - THE ONE
3124
+ * BOOKING CODE, for the trip (`request_booking`, `store` the trip's own key)
3125
+ * and for the picked room (`book_picked_room`, `store` its `pickedKey`).
3126
+ *
3127
+ * ⚠️ TWO KEYS, AND THEY ARE NOT THE SAME THING. `key` is the conversation's
3128
+ * trip key: the signed-in member, the identity a booking is made as, the
3129
+ * limits and every answer's `common` keep it. `store` is only where the LINES
3130
+ * come from and where what was booked leaves from - so a pick is booked at the
3131
+ * member rate and under the same limits, and the trip is never read or emptied.
3132
+ * `nothingThere` is what is said when the slot is empty.
3133
+ */
3134
+ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3135
+ const inTrip = deps.trips.read(store);
3136
+ if (inTrip.length === 0) {
3137
+ return {
3138
+ structured: { view: 'empty-trip', ...common(deps, key) },
3139
+ text: nothingThere,
3140
+ };
3141
+ }
3142
+ // ⚠️ ONE GROUP AT A TIME, in a market: a booking cannot be all-or-nothing
3143
+ // across two groups' systems (Marketplace, §6-1). `group` books one
3144
+ // group's stays and leaves the rest in the trip.
3145
+ const asked = deps.backend.suppliers ? str(args.group) : '';
3146
+ const group = asked ? groupNamed(deps, asked) : undefined;
3147
+ const groupOf = (l) => deps.backend.supplierOf?.(l.hotelId);
3148
+ const lines = group ? inTrip.filter((l) => groupOf(l) === group.id) : inTrip;
3149
+ if (asked && (!group || lines.length === 0)) {
3150
+ const there = [...new Set(inTrip.map(groupOf))]
3151
+ .map((id) => holderOf(deps, id).name)
3152
+ .filter(Boolean);
3153
+ const words = group
3154
+ ? `Nothing in the trip is with ${group.name}. It holds stays with ${listAnd(there)}.`
3155
+ : `No hotel group here is called "${asked}". The trip holds stays with ${listAnd(there)}.`;
3156
+ return {
3157
+ structured: { view: 'error', ...common(deps, key), error: 'no-such-group', detail: words },
3158
+ text: words,
3159
+ };
3160
+ }
3161
+ const guest = { name: str(args.name), email: str(args.email) };
3162
+ // A phone where the backend needs one: a booking paid at the hotel and
3163
+ // guaranteed by nobody, which the hotel has to be able to follow up.
3164
+ // In a market, the trip's own groups decide - not whichever group needs most.
3165
+ const phoneNeeded = deps.backend.suppliers
3166
+ ? lines.some((l) => !!holderOf(deps, deps.backend.supplierOf?.(l.hotelId)).caps.requiresPhone)
3167
+ : !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
3168
+ const phone = str(args.phone);
3169
+ const missing = [
3170
+ !guest.name && 'a name',
3171
+ !guest.email.includes('@') && 'an email',
3172
+ phoneNeeded && phone.replace(/\D/g, '').length < 6 && 'a phone number',
3173
+ // Too long is as unusable as missing - see GUEST_LIMITS.
3174
+ ...guestTooLong({ ...guest, phone }),
3175
+ ].filter((x) => typeof x === 'string');
3176
+ if (missing.length) {
3177
+ const said = missing.length > 1
3178
+ ? `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)}`
3179
+ : (missing[0] ?? '');
3180
+ return {
3181
+ structured: { view: 'needs-guest', ...common(deps, key), missing },
3182
+ text: `The booking needs ${said}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a ${phoneNeeded ? 'name, email or phone number' : 'name or email'} you know from anywhere else.`,
3183
+ };
3184
+ }
3185
+ const { requests, dropped } = readRequests(args);
3186
+ try {
3187
+ const shown = deps.trips.shown(store);
3188
+ const { booked: made, failed } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
3189
+ ...guest,
3190
+ ...(str(args.phone) ? { phone: str(args.phone) } : {}),
3191
+ ...(requests?.note ? { notes: requests.note } : {}),
3192
+ },
3193
+ // ⚠️ THE SIGNED-IN MEMBER, NOT A HAND-BUILT IDENTITY. This read
3194
+ // `{ sessionId, email }` and nothing else, so the booking was re-quoted
3195
+ // with no member and written at the PUBLIC rate - after the card had
3196
+ // shown the member rate, the saving, and the tier. A guest was quoted a
3197
+ // discount and then booked without it, which is the one step where
3198
+ // being wrong leaves a record (robin, 2026-09-21). `identity()` is the
3199
+ // only place an Identity is built; the guest's email still overrides,
3200
+ // because a booking is found by the address it was made with.
3201
+ { ...identity(deps, ctx, key), email: guest.email }, requests);
3202
+ // ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
3203
+ // and all - to ask again, or to put another hotel in its place (robin,
3204
+ // 2026-10-14).
3205
+ const stillOut = (l) => failed.some((f) => inFailedStay(l, f));
3206
+ if (lines.length === inTrip.length && !lines.some(stillOut))
3207
+ deps.trips.clear(store);
3208
+ else
3209
+ for (const l of lines)
3210
+ if (!stillOut(l))
3211
+ deps.trips.set(store, unset(l));
3212
+ // What is left is priced again, and that price is what the next
3213
+ // booking is held to (ShownQuote).
3214
+ const rest = lines.length === inTrip.length && !failed.length
3215
+ ? undefined
3216
+ : await currentTrip(deps, key, identity(deps, ctx, key), store);
3217
+ const others = rest && lines.length !== inTrip.length;
3218
+ // What could be booked instead of each, found now (robin, 2026-10-14).
3219
+ const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
3220
+ ? alternativesFor(deps, f, lines, shown, identity(deps, ctx, key))
3221
+ : Promise.resolve({ hotels: [] })));
3222
+ // ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
3223
+ // way, so neither the guest nor the model assumes something was kept.
3224
+ const signedIn = deps.members.read(key);
3225
+ let remembered;
3226
+ if (args.rememberRequests === true) {
3227
+ const lasting = (requests?.kinds ?? []).filter((k) => LASTING_REQUESTS.includes(k));
3228
+ if (!signedIn) {
3229
+ remembered = {
3230
+ kept: [],
3231
+ line: 'Nothing was remembered: saved preferences belong to a membership, and nobody is signed in.',
3232
+ };
3233
+ }
3234
+ else if (!lasting.length) {
3235
+ remembered = {
3236
+ kept: signedIn.preferences ?? [],
3237
+ line: 'Nothing new was remembered: an early check-in, a late arrival and a note are for one stay, not every stay.',
3238
+ };
3239
+ }
3240
+ else {
3241
+ // ADDED TO what is kept, not in place of it: a quiet room remembered
3242
+ // this time silently dropped the king bed remembered last time. A new
3243
+ // request does replace its opposite - twin beds over a king bed.
3244
+ const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
3245
+ const merged = [
3246
+ ...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
3247
+ ...lasting,
3248
+ ];
3249
+ const kept = await deps.backend.rememberPreferences(signedIn, merged);
3250
+ deps.members.set(key, { ...signedIn, preferences: kept });
3251
+ deps.members.setPreferences(signedIn.number, kept);
3252
+ remembered = {
3253
+ kept,
3254
+ line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
3255
+ };
3256
+ }
3257
+ }
3258
+ // The hotel's own check-in hour, so an early-check-in request is answered
3259
+ // with the time it is early against - London opens at 16:00 and Hong Kong
3260
+ // at 15:00, and a generic "check-in is in the afternoon" is not an answer.
3261
+ // Per booking: each is its own hotel, with its own hour and its own desk.
3262
+ const booked = await Promise.all(made.map(async (booking, i) => {
3263
+ const hotel = await deps.backend.getHotel(booking.hotelId);
3264
+ return {
3265
+ booking,
3266
+ // The requests that could not both be asked for are said once.
3267
+ asked: requestLines(booking, i === 0 ? dropped : [], hotel?.hotel.checkInFrom),
3268
+ };
3269
+ }));
3270
+ const [first] = booked;
3271
+ const several = booked.length > 1;
3272
+ const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
3273
+ const notBooked = failed.map((f, i) => ({
3274
+ hotelId: f.hotelId,
3275
+ hotelName: f.hotelName,
3276
+ stay: f.stay,
3277
+ ...(f.hourly ? { hourly: f.hourly } : {}),
3278
+ ...(f.roomName ? { roomName: f.roomName } : {}),
3279
+ rooms: roomsIn(lines, f),
3280
+ // What to take out of the trip to put another hotel in its place.
3281
+ roomRefs: lines.flatMap((l) => (l.kind === 'room' && inFailedStay(l, f) ? [l.roomId] : [])),
3282
+ extraRefs: lines.flatMap((l) => l.kind === 'extra' && inFailedStay(l, f) ? [l.extraId] : []),
3283
+ why: f.why,
3284
+ ...(f.code ? { code: f.code } : {}),
3285
+ ...(f.args ? { args: f.args } : {}),
3286
+ ...(instead[i]?.city ? { city: instead[i]?.city } : {}),
3287
+ alternatives: (instead[i]?.hotels ?? []).map((h) => ({
3288
+ hotelId: h.id,
3289
+ name: h.name,
3290
+ brand: h.brand,
3291
+ ...(h.forStay
3292
+ ? {
3293
+ total: formatMoney(h.forStay.total),
3294
+ room: h.forStay.room,
3295
+ ...(h.forStay.roomNames ? { roomNames: h.forStay.roomNames } : {}),
3296
+ freeCancellation: h.forStay.freeCancellation,
3297
+ }
3298
+ : {}),
3299
+ })),
3300
+ }));
3301
+ const failedText = notBooked.map((f) => `NOT BOOKED - ${f.hotelName} · ${stayWords(f.stay, f.hourly)}: ${f.why} It is still in the trip.${f.alternatives.length
3302
+ ? ` Bookable instead for the same nights: ${f.alternatives.map((a) => `${a.name}${a.room ? ` (${a.room}` : ''}${a.total ? `${a.room ? ', ' : ' ('}${a.total} for the stay)` : a.room ? ')' : ''} [hotel \`${a.hotelId}\`]`).join('; ')}.`
3303
+ : ''} The guest can ask again, take another hotel, or leave it.`);
3304
+ if (!first) {
3305
+ // Nothing booked at all: every stay said, each with its way on.
3306
+ return {
3307
+ structured: {
3308
+ view: 'booking',
3309
+ ...common(deps, key),
3310
+ paid: false,
3311
+ failed: notBooked,
3312
+ },
3313
+ text: [
3314
+ `Nothing was booked: ${notBooked.length === 1 ? 'the stay' : `none of the ${notBooked.length} stays`} could be.`,
3315
+ ...failedText,
3316
+ ].join('\n'),
3317
+ };
3318
+ }
3319
+ return {
3320
+ structured: {
3321
+ // ⚠️ 'booking' EVEN WHEN IT IS AWAITING PAYMENT: the card draws a view
3322
+ // only once it knows it, and the card-contract test holds every view
3323
+ // here to that. 香格里拉's card turns this into its own 'payment' step
3324
+ // (booking.status 'awaiting-payment', booking.payment) - and moves the
3325
+ // view with it.
3326
+ view: 'booking',
3327
+ ...common(deps, key),
3328
+ booking: madeForCard(deps, first.booking),
3329
+ // ⚠️ ONE PER STAY - see `requestBooking`. The card draws each with its
3330
+ // own code; `booking` stays the first, for a host that reads one.
3331
+ ...(several
3332
+ ? {
3333
+ bookings: booked.map(({ booking, asked }) => ({
3334
+ ...madeForCard(deps, booking),
3335
+ total: formatMoney(booking.quote.total),
3336
+ ...(asked.length ? { requested: asked } : {}),
3337
+ // Each its own warning colour - it rode on the first alone.
3338
+ ...(booking.requests?.kinds.includes('accessible-room')
3339
+ ? { accessibility: true }
3340
+ : {}),
3341
+ })),
3342
+ }
3343
+ : {}),
3344
+ paid: false,
3345
+ ...(remembered ? { remembered: remembered.kept } : {}),
3346
+ // What they come to together, said by the server like every figure on
3347
+ // the card - never added up there (r10, #15). Only in one currency.
3348
+ ...(several &&
3349
+ booked.every(({ booking }) => booking.quote.total.currency === first.booking.quote.total.currency)
3350
+ ? {
3351
+ totalAll: formatMoney({
3352
+ minor: booked.reduce((n, { booking }) => n + booking.quote.total.minor, 0),
3353
+ currency: first.booking.quote.total.currency,
3354
+ }),
3355
+ }
3356
+ : {}),
3357
+ holdUntil: first.booking.holdUntil,
3358
+ total: formatMoney(first.booking.quote.total),
3359
+ ...(first.asked.length ? { requested: first.asked } : {}),
3360
+ ...(first.booking.requests?.kinds.includes('accessible-room')
3361
+ ? { accessibility: true }
3362
+ : {}),
3363
+ ...(notBooked.length ? { failed: notBooked } : {}),
3364
+ },
3365
+ text: [
3366
+ // ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
3367
+ // that, and not counted (总线程, 2026-10-14).
3368
+ notBooked.length || (booked.length > 1 && waiting)
3369
+ ? `${booked.length - waiting} of ${booked.length + notBooked.length} stays booked${waiting ? `, ${waiting} being confirmed by the hotel - no code until it is` : ''}${notBooked.length ? `; ${notBooked.length === 1 ? 'one' : notBooked.length} could not be, said below` : ''} - what was booked stays booked.`
3370
+ : '',
3371
+ several
3372
+ ? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${holderOf(deps, first.booking.supplier).caps.changes ? 'changed or cancelled' : 'cancelled'} on its own.`
3373
+ : '',
3374
+ ...booked.flatMap(({ booking, asked }) => [
3375
+ `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${booking.hotelName} · ${stayWords(booking.stay, booking.hourly)}`,
3376
+ bookedLine(deps, booking),
3377
+ ...asked,
3378
+ ]),
3379
+ ...failedText,
3380
+ remembered?.line ?? '',
3381
+ others ? `The trip's other stays are still in it, not booked:\n${rest?.text ?? ''}` : '',
3382
+ deps.vocab.demo ? noteFor(deps.vocab) : '',
3383
+ ]
3384
+ .filter(Boolean)
3385
+ .join('\n'),
3386
+ };
3387
+ }
3388
+ catch (err) {
3389
+ const words = guestWords(err, BOOKING_FAULT);
3390
+ // The price or terms moved since the guest saw them: nothing was booked,
3391
+ // and the trip is shown again at today's - which is then what the guest
3392
+ // has seen, and what a second ask books.
3393
+ if (err.code === 'price-changed') {
3394
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3395
+ return { ...again, text: [words, again.text].join('\n\n') };
3396
+ }
3397
+ if (err.code === 'mixed-supplier') {
3398
+ const again = await currentTrip(deps, key, identity(deps, ctx, key), store);
3399
+ return {
3400
+ ...again,
3401
+ text: [
3402
+ words,
3403
+ `${t.requestBooking} with \`group\` books one group's stays and leaves the others in the trip.`,
3404
+ again.text,
3405
+ ].join('\n\n'),
3406
+ };
3407
+ }
3408
+ return {
3409
+ structured: {
3410
+ view: 'error',
3411
+ ...common(deps, key),
3412
+ error: 'not-bookable',
3413
+ detail: words,
3414
+ },
3415
+ text: words,
3416
+ };
3417
+ }
3418
+ };
2856
3419
  const answerTool = async (deps, name, rawArgs, ctx) => {
2857
3420
  const args = countsAsNumbers((rawArgs ?? {}));
2858
3421
  const t = deps.tools;
@@ -3682,23 +4245,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3682
4245
  };
3683
4246
  }
3684
4247
  if (name === t.addToTrip) {
3685
- const wanted = str(args.hotel);
3686
- const detail = await deps.backend.getHotel(wanted);
3687
- if (!detail) {
3688
- const candidates = candidatesFor(deps.backend, wanted);
3689
- return {
3690
- structured: {
3691
- view: 'error',
3692
- ...common(deps, key),
3693
- error: 'hotel-not-found',
3694
- query: wanted,
3695
- ...(candidates.length ? { candidates } : {}),
3696
- },
3697
- text: candidates.length
3698
- ? `"${wanted}" ${namesMoreThanOne(candidates, branded(deps.vocab, deps.vocab.noun))}`
3699
- : `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
3700
- };
3701
- }
4248
+ const found = await hotelNamed(deps, key, str(args.hotel));
4249
+ if ('answer' in found)
4250
+ return found.answer;
4251
+ const { detail } = found;
3702
4252
  const hotelId = detail.hotel.id;
3703
4253
  const extraRef = str(args.extra);
3704
4254
  if (extraRef) {
@@ -3727,164 +4277,24 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3727
4277
  deps.trips.set(key, line);
3728
4278
  return currentTrip(deps, key, identity(deps, ctx, key));
3729
4279
  }
3730
- const roomRef = str(args.room);
3731
- if (!roomRef) {
3732
- return {
3733
- structured: { view: 'error', ...common(deps, key), error: 'no-room-named' },
3734
- text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
3735
- };
3736
- }
3737
- // A room by the hour: today, from the start the guest gave - checked here,
3738
- // against the starts the room takes, and never moved to fit (总线程,
3739
- // 2026-10-12).
3740
- const who = identity(deps, ctx, key);
3741
- const startTime = str(args.startTime);
3742
- const today = (startTime || !asStay(args)
3743
- ? deps.backend.hourlyRooms?.(hotelId, guestsOf(args), who)
3744
- : undefined);
3745
- const byTheHour = async () => {
3746
- const sold = ((await today)?.rooms ?? []).filter((r) => r.hourly);
3747
- return sold.find((r) => r.id === roomRef) ?? namedExactly(sold, roomRef);
3748
- };
3749
- if (startTime && deps.backend.hourlyRooms) {
3750
- const hourlyRoom = await byTheHour();
3751
- if (!hourlyRoom?.hourly) {
3752
- const sold = ((await today)?.rooms ?? []).filter((r) => r.hourly);
3753
- return {
3754
- structured: {
3755
- view: 'error',
3756
- ...common(deps, key),
3757
- error: 'no-such-room',
3758
- query: roomRef,
3759
- rooms: sold.map((r) => ({
3760
- id: r.id,
3761
- name: r.name,
3762
- ...(r.names ? { names: r.names } : {}),
3763
- })),
3764
- },
3765
- text: sold.length
3766
- ? `${detail.hotel.name} has no room by the hour by that name today. It has: ${sold.map((r) => `${r.name} (\`${r.id}\`, ${windowWords(r.hourly)})`).join('; ')}.`
3767
- : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3768
- };
3769
- }
3770
- const bad = startProblem(hourlyRoom.hourly, startTime);
3771
- if (bad) {
3772
- return {
3773
- structured: {
3774
- view: 'error',
3775
- ...common(deps, key),
3776
- error: 'start-time',
3777
- query: startTime,
3778
- // The starts it does take. Not `hourly`: that is the flag above.
3779
- window: hourlyRoom.hourly,
3780
- },
3781
- text: bad,
3782
- };
3783
- }
3784
- deps.trips.set(key, {
3785
- kind: 'room',
3786
- hotelId,
3787
- roomId: hourlyRoom.id,
3788
- stay: {
3789
- checkIn: hourlyRoom.hourly.date,
3790
- checkOut: hourlyRoom.hourly.date,
3791
- guests: guestsOf(args),
3792
- },
3793
- rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3794
- hourly: {
3795
- start: clockOf(minutesOf(startTime)),
3796
- hours: hourlyRoom.hourly.hours,
3797
- },
3798
- });
3799
- return currentTrip(deps, key, who);
3800
- }
3801
- const stay = asStay(args);
3802
- // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
3803
- // connector books someone the wrong nights, and the guest finds out at the
3804
- // desk. The trip stays as it was and the answer asks.
3805
- if (!stay) {
3806
- const hourlyRoom = await byTheHour();
3807
- return {
3808
- structured: {
3809
- view: 'needs-dates',
3810
- ...common(deps, key),
3811
- hotel: detail.hotel,
3812
- room: roomRef,
3813
- ...(hourlyRoom?.hourly ? { window: hourlyRoom.hourly } : {}),
3814
- },
3815
- text: hourlyRoom?.hourly
3816
- ? `That is a room by the hour, sold for today: it takes \`startTime\` - ${windowWords(hourlyRoom.hourly)}.`
3817
- : 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
3818
- };
3819
- }
3820
- // ⚠️ TAKING A ROOM OUT NEEDS NO ROOM FOR SALE. The lookup below is of
3821
- // what is for sale, so a room that had gone since it went into the trip -
3822
- // the stay a booking could not make - could not be taken out of it
3823
- // (2026-10-14). The line in the trip is the one asked about.
3824
- if (args.rooms !== undefined && clampCount(args.rooms, MAX_ROOMS_PER_LINE) === 0) {
3825
- const held = deps.trips
3826
- .read(key)
3827
- .find((l) => l.kind === 'room' &&
3828
- l.hotelId === hotelId &&
3829
- l.roomId === roomRef &&
3830
- l.stay.checkIn === stay.checkIn &&
3831
- l.stay.checkOut === stay.checkOut);
3832
- if (held) {
3833
- deps.trips.set(key, unset(held));
3834
- return currentTrip(deps, key, identity(deps, ctx, key));
3835
- }
3836
- }
3837
- // The backend owns the calendar - it knows each hotel's own timezone, and a
3838
- // second opinion computed from this process's clock would disagree with it
3839
- // for eight hours a day.
3840
- const dated = await deps.backend.getHotel(hotelId, stay, identity(deps, ctx, key));
3841
- if (!dated?.rooms) {
4280
+ return putRoomLine(deps, t, ctx, key, key, args, detail);
4281
+ }
4282
+ // The card's own: the press on Reserve. The picked room has a slot of its
4283
+ // own (`pickedKey`), emptied first - so a second pick REPLACES the first and
4284
+ // a pick that fails leaves none - and the trip is never read or written.
4285
+ if (name === t.pickRoom) {
4286
+ const slot = pickedKey(key);
4287
+ deps.trips.clear(slot);
4288
+ if (clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE) === 0) {
3842
4289
  return {
3843
- structured: {
3844
- view: 'needs-dates',
3845
- ...common(deps, key),
3846
- hotel: detail.hotel,
3847
- room: roomRef,
3848
- why: dated?.noRooms,
3849
- ...(dated?.noRoomsCode ? { whyCode: dated.noRoomsCode } : {}),
3850
- ...(dated?.noRoomsArgs ? { whyArgs: dated.noRoomsArgs } : {}),
3851
- },
3852
- text: dated?.noRooms ?? 'Those nights cannot be booked here.',
4290
+ structured: { view: 'trip', direct: true, ...common(deps, key), lines: [], count: 0 },
4291
+ text: 'Nothing is picked.',
3853
4292
  };
3854
4293
  }
3855
- // ⚠️ BY THE OFFER'S WHOLE NAME, in any language it has: a guest who read
3856
- // 「豪华大床房 · 双早 · 不可取消」 on the card says that, and it is the same offer
3857
- // as "Deluxe King Room · Breakfast for 2 · Non-refundable". Never a part of
3858
- // it, and never a room type's bare name when it has several rates.
3859
- const room = dated.rooms.find((r) => r.id === roomRef) ?? namedExactly(dated.rooms, roomRef);
3860
- if (!room) {
3861
- return {
3862
- structured: {
3863
- view: 'error',
3864
- ...common(deps, key),
3865
- error: 'no-such-room',
3866
- query: roomRef,
3867
- rooms: dated.rooms.map((r) => ({
3868
- id: r.id,
3869
- name: r.name,
3870
- ...(r.names ? { names: r.names } : {}),
3871
- })),
3872
- // The hotel and the nights, so the card can take the guest straight
3873
- // back to its rooms rather than to the start (round-2 jury).
3874
- hotel: { id: detail.hotel.id, name: detail.hotel.name },
3875
- stay,
3876
- },
3877
- text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => (/\p{Script=Han}/u.test(roomRef) && r.names?.zh ? `${r.name} (${r.names.zh})` : r.name)).join(', ')}.`,
3878
- };
3879
- }
3880
- deps.trips.set(key, {
3881
- kind: 'room',
3882
- hotelId,
3883
- roomId: room.id,
3884
- stay,
3885
- rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3886
- });
3887
- return currentTrip(deps, key, identity(deps, ctx, key));
4294
+ const found = await hotelNamed(deps, key, str(args.hotel));
4295
+ if ('answer' in found)
4296
+ return direct(found.answer);
4297
+ return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
3888
4298
  }
3889
4299
  if (name === t.viewTrip)
3890
4300
  return currentTrip(deps, key, identity(deps, ctx, key));
@@ -3897,289 +4307,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3897
4307
  };
3898
4308
  }
3899
4309
  if (name === t.requestBooking) {
3900
- const inTrip = deps.trips.read(key);
3901
- if (inTrip.length === 0) {
3902
- return {
3903
- structured: { view: 'empty-trip', ...common(deps, key) },
3904
- text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
3905
- };
3906
- }
3907
- // ⚠️ ONE GROUP AT A TIME, in a market: a booking cannot be all-or-nothing
3908
- // across two groups' systems (Marketplace, §6-1). `group` books one
3909
- // group's stays and leaves the rest in the trip.
3910
- const asked = deps.backend.suppliers ? str(args.group) : '';
3911
- const group = asked ? groupNamed(deps, asked) : undefined;
3912
- const groupOf = (l) => deps.backend.supplierOf?.(l.hotelId);
3913
- const lines = group ? inTrip.filter((l) => groupOf(l) === group.id) : inTrip;
3914
- if (asked && (!group || lines.length === 0)) {
3915
- const there = [...new Set(inTrip.map(groupOf))]
3916
- .map((id) => holderOf(deps, id).name)
3917
- .filter(Boolean);
3918
- const words = group
3919
- ? `Nothing in the trip is with ${group.name}. It holds stays with ${listAnd(there)}.`
3920
- : `No hotel group here is called "${asked}". The trip holds stays with ${listAnd(there)}.`;
3921
- return {
3922
- structured: { view: 'error', ...common(deps, key), error: 'no-such-group', detail: words },
3923
- text: words,
3924
- };
3925
- }
3926
- const guest = { name: str(args.name), email: str(args.email) };
3927
- // A phone where the backend needs one: a booking paid at the hotel and
3928
- // guaranteed by nobody, which the hotel has to be able to follow up.
3929
- // In a market, the trip's own groups decide - not whichever group needs most.
3930
- const phoneNeeded = deps.backend.suppliers
3931
- ? lines.some((l) => !!holderOf(deps, deps.backend.supplierOf?.(l.hotelId)).caps.requiresPhone)
3932
- : !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
3933
- const phone = str(args.phone);
3934
- const missing = [
3935
- !guest.name && 'a name',
3936
- !guest.email.includes('@') && 'an email',
3937
- phoneNeeded && phone.replace(/\D/g, '').length < 6 && 'a phone number',
3938
- // Too long is as unusable as missing - see GUEST_LIMITS.
3939
- ...guestTooLong({ ...guest, phone }),
3940
- ].filter((x) => typeof x === 'string');
3941
- if (missing.length) {
3942
- const said = missing.length > 1
3943
- ? `${missing.slice(0, -1).join(', ')} and ${missing.at(-1)}`
3944
- : (missing[0] ?? '');
3945
- return {
3946
- structured: { view: 'needs-guest', ...common(deps, key), missing },
3947
- text: `The booking needs ${said}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a ${phoneNeeded ? 'name, email or phone number' : 'name or email'} you know from anywhere else.`,
3948
- };
3949
- }
3950
- const { requests, dropped } = readRequests(args);
3951
- try {
3952
- const shown = deps.trips.shown(key);
3953
- const { booked: made, failed } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
3954
- ...guest,
3955
- ...(str(args.phone) ? { phone: str(args.phone) } : {}),
3956
- ...(requests?.note ? { notes: requests.note } : {}),
3957
- },
3958
- // ⚠️ THE SIGNED-IN MEMBER, NOT A HAND-BUILT IDENTITY. This read
3959
- // `{ sessionId, email }` and nothing else, so the booking was re-quoted
3960
- // with no member and written at the PUBLIC rate - after the card had
3961
- // shown the member rate, the saving, and the tier. A guest was quoted a
3962
- // discount and then booked without it, which is the one step where
3963
- // being wrong leaves a record (robin, 2026-09-21). `identity()` is the
3964
- // only place an Identity is built; the guest's email still overrides,
3965
- // because a booking is found by the address it was made with.
3966
- { ...identity(deps, ctx, key), email: guest.email }, requests);
3967
- // ⚠️ WHAT WAS BOOKED LEAVES THE TRIP; WHAT WAS NOT STAYS IN IT, add-ons
3968
- // and all - to ask again, or to put another hotel in its place (robin,
3969
- // 2026-10-14).
3970
- const stillOut = (l) => failed.some((f) => inFailedStay(l, f));
3971
- if (lines.length === inTrip.length && !lines.some(stillOut))
3972
- deps.trips.clear(key);
3973
- else
3974
- for (const l of lines)
3975
- if (!stillOut(l))
3976
- deps.trips.set(key, unset(l));
3977
- // What is left is priced again, and that price is what the next
3978
- // booking is held to (ShownQuote).
3979
- const rest = lines.length === inTrip.length && !failed.length
3980
- ? undefined
3981
- : await currentTrip(deps, key, identity(deps, ctx, key));
3982
- const others = rest && lines.length !== inTrip.length;
3983
- // What could be booked instead of each, found now (robin, 2026-10-14).
3984
- const instead = await Promise.all(failed.map((f, i) => i < MAX_FAILED_WITH_ALTERNATIVES
3985
- ? alternativesFor(deps, f, lines, shown, identity(deps, ctx, key))
3986
- : Promise.resolve({ hotels: [] })));
3987
- // ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
3988
- // way, so neither the guest nor the model assumes something was kept.
3989
- const signedIn = deps.members.read(key);
3990
- let remembered;
3991
- if (args.rememberRequests === true) {
3992
- const lasting = (requests?.kinds ?? []).filter((k) => LASTING_REQUESTS.includes(k));
3993
- if (!signedIn) {
3994
- remembered = {
3995
- kept: [],
3996
- line: 'Nothing was remembered: saved preferences belong to a membership, and nobody is signed in.',
3997
- };
3998
- }
3999
- else if (!lasting.length) {
4000
- remembered = {
4001
- kept: signedIn.preferences ?? [],
4002
- line: 'Nothing new was remembered: an early check-in, a late arrival and a note are for one stay, not every stay.',
4003
- };
4004
- }
4005
- else {
4006
- // ADDED TO what is kept, not in place of it: a quiet room remembered
4007
- // this time silently dropped the king bed remembered last time. A new
4008
- // request does replace its opposite - twin beds over a king bed.
4009
- const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
4010
- const merged = [
4011
- ...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
4012
- ...lasting,
4013
- ];
4014
- const kept = await deps.backend.rememberPreferences(signedIn, merged);
4015
- deps.members.set(key, { ...signedIn, preferences: kept });
4016
- deps.members.setPreferences(signedIn.number, kept);
4017
- remembered = {
4018
- kept,
4019
- line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
4020
- };
4021
- }
4022
- }
4023
- // The hotel's own check-in hour, so an early-check-in request is answered
4024
- // with the time it is early against - London opens at 16:00 and Hong Kong
4025
- // at 15:00, and a generic "check-in is in the afternoon" is not an answer.
4026
- // Per booking: each is its own hotel, with its own hour and its own desk.
4027
- const booked = await Promise.all(made.map(async (booking, i) => {
4028
- const hotel = await deps.backend.getHotel(booking.hotelId);
4029
- return {
4030
- booking,
4031
- // The requests that could not both be asked for are said once.
4032
- asked: requestLines(booking, i === 0 ? dropped : [], hotel?.hotel.checkInFrom),
4033
- };
4034
- }));
4035
- const [first] = booked;
4036
- const several = booked.length > 1;
4037
- const waiting = booked.filter(({ booking }) => confirming(deps, booking)).length;
4038
- const notBooked = failed.map((f, i) => ({
4039
- hotelId: f.hotelId,
4040
- hotelName: f.hotelName,
4041
- stay: f.stay,
4042
- ...(f.hourly ? { hourly: f.hourly } : {}),
4043
- ...(f.roomName ? { roomName: f.roomName } : {}),
4044
- rooms: roomsIn(lines, f),
4045
- // What to take out of the trip to put another hotel in its place.
4046
- roomRefs: lines.flatMap((l) => (l.kind === 'room' && inFailedStay(l, f) ? [l.roomId] : [])),
4047
- extraRefs: lines.flatMap((l) => l.kind === 'extra' && inFailedStay(l, f) ? [l.extraId] : []),
4048
- why: f.why,
4049
- ...(f.code ? { code: f.code } : {}),
4050
- ...(f.args ? { args: f.args } : {}),
4051
- ...(instead[i]?.city ? { city: instead[i]?.city } : {}),
4052
- alternatives: (instead[i]?.hotels ?? []).map((h) => ({
4053
- hotelId: h.id,
4054
- name: h.name,
4055
- brand: h.brand,
4056
- ...(h.forStay
4057
- ? {
4058
- total: formatMoney(h.forStay.total),
4059
- room: h.forStay.room,
4060
- ...(h.forStay.roomNames ? { roomNames: h.forStay.roomNames } : {}),
4061
- freeCancellation: h.forStay.freeCancellation,
4062
- }
4063
- : {}),
4064
- })),
4065
- }));
4066
- const failedText = notBooked.map((f) => `NOT BOOKED - ${f.hotelName} · ${stayWords(f.stay, f.hourly)}: ${f.why} It is still in the trip.${f.alternatives.length
4067
- ? ` Bookable instead for the same nights: ${f.alternatives.map((a) => `${a.name}${a.room ? ` (${a.room}` : ''}${a.total ? `${a.room ? ', ' : ' ('}${a.total} for the stay)` : a.room ? ')' : ''} [hotel \`${a.hotelId}\`]`).join('; ')}.`
4068
- : ''} The guest can ask again, take another hotel, or leave it.`);
4069
- if (!first) {
4070
- // Nothing booked at all: every stay said, each with its way on.
4071
- return {
4072
- structured: {
4073
- view: 'booking',
4074
- ...common(deps, key),
4075
- paid: false,
4076
- failed: notBooked,
4077
- },
4078
- text: [
4079
- `Nothing was booked: ${notBooked.length === 1 ? 'the stay' : `none of the ${notBooked.length} stays`} could be.`,
4080
- ...failedText,
4081
- ].join('\n'),
4082
- };
4083
- }
4084
- return {
4085
- structured: {
4086
- // ⚠️ 'booking' EVEN WHEN IT IS AWAITING PAYMENT: the card draws a view
4087
- // only once it knows it, and the card-contract test holds every view
4088
- // here to that. 香格里拉's card turns this into its own 'payment' step
4089
- // (booking.status 'awaiting-payment', booking.payment) - and moves the
4090
- // view with it.
4091
- view: 'booking',
4092
- ...common(deps, key),
4093
- booking: madeForCard(deps, first.booking),
4094
- // ⚠️ ONE PER STAY - see `requestBooking`. The card draws each with its
4095
- // own code; `booking` stays the first, for a host that reads one.
4096
- ...(several
4097
- ? {
4098
- bookings: booked.map(({ booking, asked }) => ({
4099
- ...madeForCard(deps, booking),
4100
- total: formatMoney(booking.quote.total),
4101
- ...(asked.length ? { requested: asked } : {}),
4102
- // Each its own warning colour - it rode on the first alone.
4103
- ...(booking.requests?.kinds.includes('accessible-room')
4104
- ? { accessibility: true }
4105
- : {}),
4106
- })),
4107
- }
4108
- : {}),
4109
- paid: false,
4110
- ...(remembered ? { remembered: remembered.kept } : {}),
4111
- // What they come to together, said by the server like every figure on
4112
- // the card - never added up there (r10, #15). Only in one currency.
4113
- ...(several &&
4114
- booked.every(({ booking }) => booking.quote.total.currency === first.booking.quote.total.currency)
4115
- ? {
4116
- totalAll: formatMoney({
4117
- minor: booked.reduce((n, { booking }) => n + booking.quote.total.minor, 0),
4118
- currency: first.booking.quote.total.currency,
4119
- }),
4120
- }
4121
- : {}),
4122
- holdUntil: first.booking.holdUntil,
4123
- total: formatMoney(first.booking.quote.total),
4124
- ...(first.asked.length ? { requested: first.asked } : {}),
4125
- ...(first.booking.requests?.kinds.includes('accessible-room')
4126
- ? { accessibility: true }
4127
- : {}),
4128
- ...(notBooked.length ? { failed: notBooked } : {}),
4129
- },
4130
- text: [
4131
- // ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
4132
- // that, and not counted (总线程, 2026-10-14).
4133
- notBooked.length || (booked.length > 1 && waiting)
4134
- ? `${booked.length - waiting} of ${booked.length + notBooked.length} stays booked${waiting ? `, ${waiting} being confirmed by the hotel - no code until it is` : ''}${notBooked.length ? `; ${notBooked.length === 1 ? 'one' : notBooked.length} could not be, said below` : ''} - what was booked stays booked.`
4135
- : '',
4136
- several
4137
- ? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${holderOf(deps, first.booking.supplier).caps.changes ? 'changed or cancelled' : 'cancelled'} on its own.`
4138
- : '',
4139
- ...booked.flatMap(({ booking, asked }) => [
4140
- `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${booking.hotelName} · ${stayWords(booking.stay, booking.hourly)}`,
4141
- bookedLine(deps, booking),
4142
- ...asked,
4143
- ]),
4144
- ...failedText,
4145
- remembered?.line ?? '',
4146
- others ? `The trip's other stays are still in it, not booked:\n${rest?.text ?? ''}` : '',
4147
- deps.vocab.demo ? noteFor(deps.vocab) : '',
4148
- ]
4149
- .filter(Boolean)
4150
- .join('\n'),
4151
- };
4152
- }
4153
- catch (err) {
4154
- const words = guestWords(err, BOOKING_FAULT);
4155
- // The price or terms moved since the guest saw them: nothing was booked,
4156
- // and the trip is shown again at today's - which is then what the guest
4157
- // has seen, and what a second ask books.
4158
- if (err.code === 'price-changed') {
4159
- const again = await currentTrip(deps, key, identity(deps, ctx, key));
4160
- return { ...again, text: [words, again.text].join('\n\n') };
4161
- }
4162
- if (err.code === 'mixed-supplier') {
4163
- const again = await currentTrip(deps, key, identity(deps, ctx, key));
4164
- return {
4165
- ...again,
4166
- text: [
4167
- words,
4168
- `${t.requestBooking} with \`group\` books one group's stays and leaves the others in the trip.`,
4169
- again.text,
4170
- ].join('\n\n'),
4171
- };
4172
- }
4173
- return {
4174
- structured: {
4175
- view: 'error',
4176
- ...common(deps, key),
4177
- error: 'not-bookable',
4178
- detail: words,
4179
- },
4180
- text: words,
4181
- };
4182
- }
4310
+ return bookTripLines(deps, t, ctx, key, key, args, `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`);
4311
+ }
4312
+ // The card's own: the room the guest picked with Reserve, booked exactly as
4313
+ // a trip is - and the trip left as it is.
4314
+ if (name === t.bookPicked) {
4315
+ return direct(await bookTripLines(deps, t, ctx, key, pickedKey(key), args, 'Nothing is picked: the guest presses Reserve on a room first.'));
4183
4316
  }
4184
4317
  if (name === t.listBookings) {
4185
4318
  const who = identity(deps, ctx, key, args);