@curless/hotel-storefront 0.1.0 → 0.2.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/dist/__tests__/card-contract.test.js +13 -0
- package/dist/__tests__/card-contract.test.js.map +1 -1
- package/dist/__tests__/data.test.js +28 -3
- package/dist/__tests__/data.test.js.map +1 -1
- package/dist/__tests__/demo-hotels.test.js +25 -11
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/present.test.d.ts +2 -0
- package/dist/__tests__/present.test.d.ts.map +1 -0
- package/dist/__tests__/present.test.js +140 -0
- package/dist/__tests__/present.test.js.map +1 -0
- package/dist/__tests__/server.test.js +63 -4
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +129 -0
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +30 -6
- package/dist/backend.d.ts.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +88 -13
- package/dist/data.js.map +1 -1
- package/dist/demo-hotels.d.ts +24 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +136 -13
- package/dist/demo-hotels.js.map +1 -1
- package/dist/fence.d.ts +9 -3
- package/dist/fence.d.ts.map +1 -1
- package/dist/fence.js +0 -0
- package/dist/fence.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/members.d.ts +14 -0
- package/dist/members.d.ts.map +1 -0
- package/dist/members.js +56 -0
- package/dist/members.js.map +1 -0
- package/dist/present.d.ts +129 -0
- package/dist/present.d.ts.map +1 -0
- package/dist/present.js +188 -0
- package/dist/present.js.map +1 -0
- package/dist/server.d.ts +27 -10
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +27 -7
- package/dist/server.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +27 -2
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +174 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +464 -50
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +57 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +1 -1
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +1 -1
- package/dist/vocab.js.map +1 -1
- package/package.json +11 -9
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isDay } from './dates.js';
|
|
2
2
|
import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
|
|
3
|
-
import { formatMoney } from './money.js';
|
|
3
|
+
import { formatMoney, multiplyMoney } from './money.js';
|
|
4
|
+
import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
|
|
4
5
|
import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, tripKey, } from './trip.js';
|
|
5
6
|
import { DEMO_NOTE } from './vocab.js';
|
|
6
7
|
// The eleven things a guest can do: find hotels, look at one on dates, see what
|
|
@@ -83,7 +84,7 @@ export const buildTools = (n, v) => {
|
|
|
83
84
|
},
|
|
84
85
|
{
|
|
85
86
|
name: n.getHotel,
|
|
86
|
-
description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
|
|
87
|
+
description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
|
|
87
88
|
annotations: reads(`View one ${v.brand} ${v.noun}`, open),
|
|
88
89
|
inputSchema: {
|
|
89
90
|
type: 'object',
|
|
@@ -93,6 +94,10 @@ export const buildTools = (n, v) => {
|
|
|
93
94
|
description: `The ${v.noun}'s name or id${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
|
|
94
95
|
},
|
|
95
96
|
...stayProps,
|
|
97
|
+
rooms: {
|
|
98
|
+
type: 'number',
|
|
99
|
+
description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
|
|
100
|
+
},
|
|
96
101
|
},
|
|
97
102
|
required: ['hotel'],
|
|
98
103
|
},
|
|
@@ -225,6 +230,101 @@ export const buildTools = (n, v) => {
|
|
|
225
230
|
required: ['booking'],
|
|
226
231
|
},
|
|
227
232
|
},
|
|
233
|
+
{
|
|
234
|
+
name: n.compare,
|
|
235
|
+
// ⚠️ NO PRICES IN, NO PRICES OUT OF THE MODEL. The arguments carry ids and
|
|
236
|
+
// prose; every figure on the card is read off the hotel's own record. A
|
|
237
|
+
// model that could send a price could one day send the wrong one, onto a
|
|
238
|
+
// card that looks official.
|
|
239
|
+
description: `Puts two to four ${v.nounPlural} side by side as one card. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
|
|
240
|
+
annotations: reads(`Compare ${v.brand} ${v.nounPlural}`, open),
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
entries: {
|
|
245
|
+
type: 'array',
|
|
246
|
+
minItems: 2,
|
|
247
|
+
maxItems: MAX_COMPARED,
|
|
248
|
+
description: `The ${v.nounPlural} to set beside each other, in the order they should appear`,
|
|
249
|
+
items: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
|
|
253
|
+
bestFor: {
|
|
254
|
+
type: 'string',
|
|
255
|
+
description: 'One line on who this one suits, in your own words',
|
|
256
|
+
},
|
|
257
|
+
pros: { type: 'array', items: { type: 'string' }, maxItems: MAX_PROS },
|
|
258
|
+
cons: { type: 'array', items: { type: 'string' }, maxItems: MAX_CONS },
|
|
259
|
+
},
|
|
260
|
+
required: ['hotel'],
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
recommended: { type: 'string', description: `The one ${v.noun} to mark as the pick` },
|
|
264
|
+
title: { type: 'string', description: 'A heading for the card' },
|
|
265
|
+
...stayProps,
|
|
266
|
+
},
|
|
267
|
+
required: ['entries'],
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: n.itinerary,
|
|
272
|
+
description: `A day-by-day plan as one card: each day carries its own label, an optional note, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records. Each item is marked with whether it is in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
|
|
273
|
+
annotations: reads(`Lay out a ${v.brand} itinerary`, open),
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
title: { type: 'string', description: 'What to call the trip' },
|
|
278
|
+
days: {
|
|
279
|
+
type: 'array',
|
|
280
|
+
minItems: 1,
|
|
281
|
+
maxItems: MAX_DAYS,
|
|
282
|
+
items: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
label: { type: 'string', description: 'e.g. "Day 1 — Arrive in Hong Kong"' },
|
|
286
|
+
note: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
description: 'One or two lines on the day, in your own words',
|
|
289
|
+
},
|
|
290
|
+
items: {
|
|
291
|
+
type: 'array',
|
|
292
|
+
items: { type: 'string' },
|
|
293
|
+
maxItems: MAX_ITEMS_PER_DAY,
|
|
294
|
+
description: `${v.nounPlural} and add-ons for that day, by name or id`,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
required: ['label'],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
...stayProps,
|
|
301
|
+
},
|
|
302
|
+
required: ['title', 'days'],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: n.signIn,
|
|
307
|
+
// ⚠️ A MEMBERSHIP NUMBER AND A SURNAME. No password appears in this
|
|
308
|
+
// schema because none exists: the connector has nowhere to put one, and
|
|
309
|
+
// a description that mentioned passwords would invite a model to ask for
|
|
310
|
+
// one in a chat transcript.
|
|
311
|
+
description: `Signs a ${v.brand} loyalty member in for this conversation with their membership number and the surname on it, and answers with their tier, their point balance and what the tier takes off the public rate. Room rates and trip totals are then quoted at the member rate until they are signed out.`,
|
|
312
|
+
annotations: edits(`Sign in to ${v.brand}`, open),
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: 'object',
|
|
315
|
+
properties: {
|
|
316
|
+
number: { type: 'string', description: 'The membership number, as it is printed' },
|
|
317
|
+
surname: { type: 'string', description: 'The surname on that membership' },
|
|
318
|
+
},
|
|
319
|
+
required: ['number', 'surname'],
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: n.signOut,
|
|
324
|
+
description: 'Ends the membership sign-in for this conversation. Rates go back to the public ones; the trip and any bookings are left as they are.',
|
|
325
|
+
annotations: edits(`Sign out of ${v.brand}`, open),
|
|
326
|
+
inputSchema: { type: 'object', properties: {} },
|
|
327
|
+
},
|
|
228
328
|
{
|
|
229
329
|
name: n.policies,
|
|
230
330
|
description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
|
|
@@ -269,6 +369,10 @@ const TRIP_SCHEMA = {
|
|
|
269
369
|
subtotal: STR,
|
|
270
370
|
taxesAndFees: STR,
|
|
271
371
|
total: STR,
|
|
372
|
+
/** Who is signed in, on every payload. */
|
|
373
|
+
member: OBJ,
|
|
374
|
+
/** What the tier took off THIS trip, and what it earns. */
|
|
375
|
+
memberSaving: OBJ,
|
|
272
376
|
days: ARR,
|
|
273
377
|
dropped: ARR,
|
|
274
378
|
},
|
|
@@ -302,7 +406,9 @@ export const buildOutputSchemas = (n) => ({
|
|
|
302
406
|
demo: BOOL,
|
|
303
407
|
demoNote: STR,
|
|
304
408
|
hotels: ARR,
|
|
409
|
+
/** How many MATCHED. `shown` is how many are in `hotels`. */
|
|
305
410
|
total: { type: 'number' },
|
|
411
|
+
shown: { type: 'number' },
|
|
306
412
|
cities: ARR,
|
|
307
413
|
query: STR,
|
|
308
414
|
},
|
|
@@ -322,6 +428,8 @@ export const buildOutputSchemas = (n) => ({
|
|
|
322
428
|
rooms: ARR,
|
|
323
429
|
/** True when no dates were given: the card asks for them instead of showing rooms. */
|
|
324
430
|
needsDates: BOOL,
|
|
431
|
+
/** How many rooms of one type the list was filtered to. */
|
|
432
|
+
rooms_wanted: { type: 'number' },
|
|
325
433
|
noRooms: STR,
|
|
326
434
|
extras: ARR,
|
|
327
435
|
policies: ARR,
|
|
@@ -347,7 +455,7 @@ export const buildOutputSchemas = (n) => ({
|
|
|
347
455
|
[n.requestBooking]: {
|
|
348
456
|
type: 'object',
|
|
349
457
|
properties: {
|
|
350
|
-
view: { enum: ['booking', 'empty-trip', 'error'] },
|
|
458
|
+
view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error'] },
|
|
351
459
|
brand: STR,
|
|
352
460
|
toolNames: OBJ,
|
|
353
461
|
demo: BOOL,
|
|
@@ -375,6 +483,56 @@ export const buildOutputSchemas = (n) => ({
|
|
|
375
483
|
},
|
|
376
484
|
[n.previewCancellation]: CANCEL_SCHEMA,
|
|
377
485
|
[n.cancelBooking]: CANCEL_SCHEMA,
|
|
486
|
+
[n.compare]: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: {
|
|
489
|
+
view: { enum: ['comparison', 'nothing-to-compare'] },
|
|
490
|
+
brand: STR,
|
|
491
|
+
toolNames: OBJ,
|
|
492
|
+
demo: BOOL,
|
|
493
|
+
demoNote: STR,
|
|
494
|
+
title: STR,
|
|
495
|
+
stay: OBJ,
|
|
496
|
+
columns: ARR,
|
|
497
|
+
dropped: ARR,
|
|
498
|
+
},
|
|
499
|
+
required: ['view'],
|
|
500
|
+
},
|
|
501
|
+
[n.itinerary]: {
|
|
502
|
+
type: 'object',
|
|
503
|
+
properties: {
|
|
504
|
+
view: { enum: ['itinerary', 'empty-itinerary'] },
|
|
505
|
+
brand: STR,
|
|
506
|
+
toolNames: OBJ,
|
|
507
|
+
demo: BOOL,
|
|
508
|
+
demoNote: STR,
|
|
509
|
+
title: STR,
|
|
510
|
+
stay: OBJ,
|
|
511
|
+
days: ARR,
|
|
512
|
+
/** How many of the planned items are actually in the trip. Computed, never written. */
|
|
513
|
+
inTrip: { type: 'number' },
|
|
514
|
+
planned: { type: 'number' },
|
|
515
|
+
dropped: ARR,
|
|
516
|
+
},
|
|
517
|
+
required: ['view'],
|
|
518
|
+
},
|
|
519
|
+
[n.signIn]: {
|
|
520
|
+
type: 'object',
|
|
521
|
+
properties: {
|
|
522
|
+
view: { enum: ['member', 'member-not-found'] },
|
|
523
|
+
brand: STR,
|
|
524
|
+
toolNames: OBJ,
|
|
525
|
+
demo: BOOL,
|
|
526
|
+
demoNote: STR,
|
|
527
|
+
member: OBJ,
|
|
528
|
+
},
|
|
529
|
+
required: ['view'],
|
|
530
|
+
},
|
|
531
|
+
[n.signOut]: {
|
|
532
|
+
type: 'object',
|
|
533
|
+
properties: { view: { const: 'signed-out' }, brand: STR, toolNames: OBJ },
|
|
534
|
+
required: ['view'],
|
|
535
|
+
},
|
|
378
536
|
[n.policies]: {
|
|
379
537
|
type: 'object',
|
|
380
538
|
properties: {
|
|
@@ -401,13 +559,30 @@ const asStay = (args) => {
|
|
|
401
559
|
const guests = typeof args.guests === 'number' ? Math.floor(args.guests) : 2;
|
|
402
560
|
return { checkIn, checkOut, guests };
|
|
403
561
|
};
|
|
404
|
-
/**
|
|
405
|
-
|
|
562
|
+
/**
|
|
563
|
+
* What every payload carries, so the card can draw a header and say whose data
|
|
564
|
+
* this is.
|
|
565
|
+
*
|
|
566
|
+
* ⚠️ THE MEMBER BELONGS HERE, not on the sign-in view alone. A member rate is a
|
|
567
|
+
* fact about EVERY price on the card, so the tier has to travel with every
|
|
568
|
+
* answer - otherwise the header says "sign in" over a list of discounted rates,
|
|
569
|
+
* which is what it did the first time (2026-09-18). The name and the number are
|
|
570
|
+
* left out: the chip needs a tier, and a payload that repeats somebody's
|
|
571
|
+
* membership number on every call is carrying it further than it has to go.
|
|
572
|
+
*/
|
|
573
|
+
const memberBadge = (deps, key) => {
|
|
574
|
+
const m = deps.members.read(key);
|
|
575
|
+
return m
|
|
576
|
+
? { member: { programme: m.programme, tier: { name: m.tier.name, discount: m.tier.discount } } }
|
|
577
|
+
: {};
|
|
578
|
+
};
|
|
579
|
+
const common = (deps, key) => ({
|
|
406
580
|
brand: deps.vocab.brand,
|
|
407
581
|
...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
|
|
408
582
|
toolNames: deps.tools,
|
|
409
583
|
vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
|
|
410
584
|
...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
|
|
585
|
+
...(key ? memberBadge(deps, key) : {}),
|
|
411
586
|
});
|
|
412
587
|
/** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
|
|
413
588
|
const quoteForCard = (quote) => ({
|
|
@@ -431,6 +606,12 @@ const quoteForCard = (quote) => ({
|
|
|
431
606
|
subtotal: formatMoney(quote.subtotal),
|
|
432
607
|
taxesAndFees: formatMoney(quote.taxesAndFees),
|
|
433
608
|
total: formatMoney(quote.total),
|
|
609
|
+
// ⚠️ NOT `member`: that key means "who is signed in" on every payload, and
|
|
610
|
+
// the quote's block is a different shape (a tier NAME, a saving, points).
|
|
611
|
+
// Two shapes on one key is how a card reads `member.tier.name` off a string.
|
|
612
|
+
...(quote.member
|
|
613
|
+
? { memberSaving: { ...quote.member, saved: formatMoney(quote.member.saved) } }
|
|
614
|
+
: {}),
|
|
434
615
|
...(quote.stay ? { stay: quote.stay } : {}),
|
|
435
616
|
...(quote.dropped ? { dropped: quote.dropped } : {}),
|
|
436
617
|
});
|
|
@@ -450,16 +631,46 @@ const tripText = (quote, demo) => {
|
|
|
450
631
|
.join('\n');
|
|
451
632
|
};
|
|
452
633
|
const bookingLine = (b) => `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${b.status}`;
|
|
453
|
-
const identity = (ctx, args) => {
|
|
634
|
+
const identity = (deps, ctx, key, args = {}) => {
|
|
454
635
|
const email = str(args.email);
|
|
455
|
-
|
|
636
|
+
const member = deps.members.read(key);
|
|
637
|
+
return {
|
|
638
|
+
sessionId: ctx.session,
|
|
639
|
+
...(email ? { email } : {}),
|
|
640
|
+
// ⚠️ THE MEMBER BUYS A RATE, NOT A LOOKUP. `listBookings` and the
|
|
641
|
+
// cancellation tools still key on the conversation and on the email the
|
|
642
|
+
// booking was made with - a membership number plus a surname is what a desk
|
|
643
|
+
// checks, not authentication, and it must never become a way to read
|
|
644
|
+
// somebody else's booking.
|
|
645
|
+
...(member ? { member } : {}),
|
|
646
|
+
};
|
|
456
647
|
};
|
|
457
|
-
/**
|
|
458
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Why this hotel cannot join the trip that is already there - or null.
|
|
650
|
+
*
|
|
651
|
+
* One trip, one currency. Not a limitation to route around: converting HKD to
|
|
652
|
+
* CNY means picking a rate, and a rate we picked is a number the guest would
|
|
653
|
+
* read off a card as if the hotel had said it.
|
|
654
|
+
*/
|
|
655
|
+
const currencyClash = async (deps, key, hotel) => {
|
|
459
656
|
const lines = deps.trips.read(key);
|
|
657
|
+
if (lines.length === 0)
|
|
658
|
+
return null;
|
|
659
|
+
const theirs = hotel.fromNightly?.currency;
|
|
660
|
+
if (!theirs)
|
|
661
|
+
return null;
|
|
460
662
|
const quote = await deps.backend.quote({ lines });
|
|
663
|
+
const ours = quote.lines.length ? quote.total.currency : undefined;
|
|
664
|
+
if (!ours || ours === theirs)
|
|
665
|
+
return null;
|
|
666
|
+
return `This trip is priced in ${ours} and ${hotel.name} charges in ${theirs}. No exchange rate is invented here - book those nights as their own trip, or clear this one first.`;
|
|
667
|
+
};
|
|
668
|
+
/** The trip, priced, as both views need it. */
|
|
669
|
+
const currentTrip = async (deps, key, who) => {
|
|
670
|
+
const lines = deps.trips.read(key);
|
|
671
|
+
const quote = await deps.backend.quote({ lines }, who);
|
|
461
672
|
return {
|
|
462
|
-
structured: { view: 'trip', ...common(deps), ...quoteForCard(quote), count: lines.length },
|
|
673
|
+
structured: { view: 'trip', ...common(deps, key), ...quoteForCard(quote), count: lines.length },
|
|
463
674
|
text: tripText(quote, deps.vocab.demo),
|
|
464
675
|
};
|
|
465
676
|
};
|
|
@@ -469,15 +680,19 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
469
680
|
const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
|
|
470
681
|
if (name === t.listHotels) {
|
|
471
682
|
const query = str(args.query);
|
|
472
|
-
const hotels = await deps.backend.searchHotels({
|
|
683
|
+
const { hotels, total } = await deps.backend.searchHotels({
|
|
473
684
|
query: query || undefined,
|
|
474
685
|
city: str(args.city) || undefined,
|
|
475
686
|
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
476
687
|
});
|
|
477
688
|
const cities = [...new Set(hotels.map((h) => h.city))];
|
|
689
|
+
// How many were left behind. The group has ninety-odd properties, so an
|
|
690
|
+
// unnarrowed browse is a page of a much longer list, and neither the card
|
|
691
|
+
// nor the model may present the page as the whole answer.
|
|
692
|
+
const more = Math.max(0, total - hotels.length);
|
|
478
693
|
if (hotels.length === 0) {
|
|
479
694
|
return {
|
|
480
|
-
structured: { view: 'no-hotels', ...common(deps), hotels: [], total: 0, query },
|
|
695
|
+
structured: { view: 'no-hotels', ...common(deps, key), hotels: [], total: 0, query },
|
|
481
696
|
text: `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
|
|
482
697
|
};
|
|
483
698
|
}
|
|
@@ -491,22 +706,28 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
491
706
|
return {
|
|
492
707
|
structured: {
|
|
493
708
|
view: 'hotels',
|
|
494
|
-
...common(deps),
|
|
709
|
+
...common(deps, key),
|
|
495
710
|
hotels: forCard,
|
|
496
|
-
total
|
|
711
|
+
total,
|
|
712
|
+
shown: hotels.length,
|
|
497
713
|
cities,
|
|
498
714
|
query,
|
|
499
715
|
},
|
|
500
|
-
text:
|
|
716
|
+
text: [
|
|
717
|
+
`${total} ${deps.vocab.brand} ${total === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where}`,
|
|
718
|
+
more > 0
|
|
719
|
+
? `— the ${hotels.length} below, and ${more} more. Narrow by city or country, or ask for a bigger \`limit\`.`
|
|
720
|
+
: '— tap one to see rooms.',
|
|
721
|
+
].join(' '),
|
|
501
722
|
};
|
|
502
723
|
}
|
|
503
724
|
if (name === t.getHotel) {
|
|
504
725
|
const wanted = str(args.hotel);
|
|
505
726
|
const stay = asStay(args);
|
|
506
|
-
const detail = await deps.backend.getHotel(wanted, stay);
|
|
727
|
+
const detail = await deps.backend.getHotel(wanted, stay, identity(deps, ctx, key));
|
|
507
728
|
if (!detail) {
|
|
508
729
|
return {
|
|
509
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
730
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
510
731
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
|
|
511
732
|
};
|
|
512
733
|
}
|
|
@@ -515,7 +736,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
515
736
|
const policies = await deps.backend.policies(h.id);
|
|
516
737
|
const extras = await deps.backend.extrasFor(h.id, stay);
|
|
517
738
|
const base = {
|
|
518
|
-
...common(deps),
|
|
739
|
+
...common(deps, key),
|
|
519
740
|
hotel: {
|
|
520
741
|
...h,
|
|
521
742
|
summary: description.text,
|
|
@@ -542,17 +763,48 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
542
763
|
text: detail.noRooms ?? 'Nothing is available for those nights.',
|
|
543
764
|
};
|
|
544
765
|
}
|
|
545
|
-
|
|
766
|
+
// ⚠️ THE ROOM COUNT FILTERS THE LIST, it does not just ride along. A guest
|
|
767
|
+
// who needs two rooms and is shown a type with one left finds out when the
|
|
768
|
+
// trip drops the line, which is later and worse than not offering it.
|
|
769
|
+
const roomsWanted = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(Number(args.rooms) || 1)));
|
|
770
|
+
const enough = detail.rooms.filter((r) => r.roomsLeft >= roomsWanted);
|
|
771
|
+
if (enough.length === 0) {
|
|
772
|
+
return {
|
|
773
|
+
structured: {
|
|
774
|
+
view: 'no-rooms',
|
|
775
|
+
...base,
|
|
776
|
+
stay,
|
|
777
|
+
rooms_wanted: roomsWanted,
|
|
778
|
+
noRooms: `No room type here has ${roomsWanted} left for those nights.`,
|
|
779
|
+
},
|
|
780
|
+
text: `No room type at ${h.name} has ${roomsWanted} left for ${stay.checkIn} to ${stay.checkOut}.`,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const rooms = enough.map((r) => ({
|
|
546
784
|
...r,
|
|
547
785
|
nightly: formatMoney(r.nightly),
|
|
548
786
|
stayTotal: formatMoney(r.stayTotal),
|
|
549
787
|
taxesAndFees: formatMoney(r.taxesAndFees),
|
|
788
|
+
// ⚠️ THE NUMBER THE GUEST IS LOOKING AT HAS TO BE THE ONE THEY ASKED FOR.
|
|
789
|
+
// With two rooms picked, the card was still showing one room's total and
|
|
790
|
+
// nothing said so - robin changed the count and watched the price not
|
|
791
|
+
// move (2026-09-18). Multiplied HERE, in minor units, because the card
|
|
792
|
+
// holds formatted strings and must never do arithmetic on money.
|
|
793
|
+
...(roomsWanted > 1
|
|
794
|
+
? { roomsTotal: formatMoney(multiplyMoney(r.stayTotal, roomsWanted)) }
|
|
795
|
+
: {}),
|
|
796
|
+
// ⚠️ THE PUBLIC RATE IS MONEY TOO. It went out as a raw
|
|
797
|
+
// `{ minor, currency }` and the card would have drawn "[object Object]"
|
|
798
|
+
// through the middle with a line - caught by the test that asserted the
|
|
799
|
+
// string, not by anyone looking at it.
|
|
800
|
+
...(r.publicNightly ? { publicNightly: formatMoney(r.publicNightly) } : {}),
|
|
801
|
+
...(r.publicStayTotal ? { publicStayTotal: formatMoney(r.publicStayTotal) } : {}),
|
|
550
802
|
}));
|
|
551
803
|
return {
|
|
552
|
-
structured: { view: 'hotel', ...base, stay, rooms },
|
|
804
|
+
structured: { view: 'hotel', ...base, stay, rooms, rooms_wanted: roomsWanted },
|
|
553
805
|
text: [
|
|
554
|
-
`**${h.name}** · ${
|
|
555
|
-
...
|
|
806
|
+
`**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
|
|
807
|
+
...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.refundable ? `, free to cancel${r.freeCancellationUntil ? ` until ${r.freeCancellationUntil.slice(0, 10)}` : ''}` : ', non-refundable'}`),
|
|
556
808
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
557
809
|
]
|
|
558
810
|
.filter(Boolean)
|
|
@@ -564,7 +816,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
564
816
|
const detail = await deps.backend.getHotel(wanted);
|
|
565
817
|
if (!detail) {
|
|
566
818
|
return {
|
|
567
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
819
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
568
820
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
569
821
|
};
|
|
570
822
|
}
|
|
@@ -572,7 +824,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
572
824
|
return {
|
|
573
825
|
structured: {
|
|
574
826
|
view: 'extras',
|
|
575
|
-
...common(deps),
|
|
827
|
+
...common(deps, key),
|
|
576
828
|
hotel: detail.hotel,
|
|
577
829
|
extras: extras.map((e) => ({
|
|
578
830
|
...e,
|
|
@@ -593,7 +845,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
593
845
|
const detail = await deps.backend.getHotel(wanted);
|
|
594
846
|
if (!detail) {
|
|
595
847
|
return {
|
|
596
|
-
structured: {
|
|
848
|
+
structured: {
|
|
849
|
+
view: 'error',
|
|
850
|
+
...common(deps, key),
|
|
851
|
+
error: 'hotel-not-found',
|
|
852
|
+
query: wanted,
|
|
853
|
+
},
|
|
597
854
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
598
855
|
};
|
|
599
856
|
}
|
|
@@ -604,10 +861,27 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
604
861
|
const extra = extras.find((e) => e.id === extraRef || e.name.toLowerCase() === extraRef.toLowerCase());
|
|
605
862
|
if (!extra) {
|
|
606
863
|
return {
|
|
607
|
-
structured: {
|
|
864
|
+
structured: {
|
|
865
|
+
view: 'error',
|
|
866
|
+
...common(deps, key),
|
|
867
|
+
error: 'no-such-extra',
|
|
868
|
+
query: extraRef,
|
|
869
|
+
},
|
|
608
870
|
text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
|
|
609
871
|
};
|
|
610
872
|
}
|
|
873
|
+
const clash = await currencyClash(deps, key, detail.hotel);
|
|
874
|
+
if (clash) {
|
|
875
|
+
return {
|
|
876
|
+
structured: {
|
|
877
|
+
view: 'error',
|
|
878
|
+
...common(deps, key),
|
|
879
|
+
error: 'mixed-currency',
|
|
880
|
+
detail: clash,
|
|
881
|
+
},
|
|
882
|
+
text: clash,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
611
885
|
const line = {
|
|
612
886
|
kind: 'extra',
|
|
613
887
|
hotelId,
|
|
@@ -616,12 +890,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
616
890
|
...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
|
|
617
891
|
};
|
|
618
892
|
deps.trips.set(key, line);
|
|
619
|
-
return currentTrip(deps, key);
|
|
893
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
620
894
|
}
|
|
621
895
|
const roomRef = str(args.room);
|
|
622
896
|
if (!roomRef) {
|
|
623
897
|
return {
|
|
624
|
-
structured: { view: 'error', ...common(deps), error: 'no-room-named' },
|
|
898
|
+
structured: { view: 'error', ...common(deps, key), error: 'no-room-named' },
|
|
625
899
|
text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
|
|
626
900
|
};
|
|
627
901
|
}
|
|
@@ -631,19 +905,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
631
905
|
// desk. The trip stays as it was and the answer asks.
|
|
632
906
|
if (!stay) {
|
|
633
907
|
return {
|
|
634
|
-
structured: {
|
|
908
|
+
structured: {
|
|
909
|
+
view: 'needs-dates',
|
|
910
|
+
...common(deps, key),
|
|
911
|
+
hotel: detail.hotel,
|
|
912
|
+
room: roomRef,
|
|
913
|
+
},
|
|
635
914
|
text: 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
|
|
636
915
|
};
|
|
637
916
|
}
|
|
638
917
|
// The backend owns the calendar - it knows each hotel's own timezone, and a
|
|
639
918
|
// second opinion computed from this process's clock would disagree with it
|
|
640
919
|
// for eight hours a day.
|
|
641
|
-
const dated = await deps.backend.getHotel(hotelId, stay);
|
|
920
|
+
const dated = await deps.backend.getHotel(hotelId, stay, identity(deps, ctx, key));
|
|
642
921
|
if (!dated?.rooms) {
|
|
643
922
|
return {
|
|
644
923
|
structured: {
|
|
645
924
|
view: 'needs-dates',
|
|
646
|
-
...common(deps),
|
|
925
|
+
...common(deps, key),
|
|
647
926
|
hotel: detail.hotel,
|
|
648
927
|
room: roomRef,
|
|
649
928
|
why: dated?.noRooms,
|
|
@@ -656,7 +935,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
656
935
|
return {
|
|
657
936
|
structured: {
|
|
658
937
|
view: 'error',
|
|
659
|
-
...common(deps),
|
|
938
|
+
...common(deps, key),
|
|
660
939
|
error: 'no-such-room',
|
|
661
940
|
query: roomRef,
|
|
662
941
|
rooms: dated.rooms.map((r) => ({ id: r.id, name: r.name })),
|
|
@@ -664,6 +943,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
664
943
|
text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
|
|
665
944
|
};
|
|
666
945
|
}
|
|
946
|
+
// ⚠️ A LINE THAT WILL NEVER PRICE DOES NOT GO IN. The quote drops a hotel
|
|
947
|
+
// billing in another currency - no exchange rate is invented here - but
|
|
948
|
+
// until now the line still went into the trip, so the guest carried
|
|
949
|
+
// something that was silently dropped from every total from then on
|
|
950
|
+
// (2026-09-18). Refused here, with the trip left exactly as it was; the
|
|
951
|
+
// quote's own drop stays as the backstop for a hotel whose currency moves.
|
|
952
|
+
const wrongCurrency = await currencyClash(deps, key, detail.hotel);
|
|
953
|
+
if (wrongCurrency) {
|
|
954
|
+
return {
|
|
955
|
+
structured: {
|
|
956
|
+
view: 'error',
|
|
957
|
+
...common(deps, key),
|
|
958
|
+
error: 'mixed-currency',
|
|
959
|
+
detail: wrongCurrency,
|
|
960
|
+
},
|
|
961
|
+
text: wrongCurrency,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
667
964
|
deps.trips.set(key, {
|
|
668
965
|
kind: 'room',
|
|
669
966
|
hotelId,
|
|
@@ -671,14 +968,14 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
671
968
|
stay,
|
|
672
969
|
rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
|
|
673
970
|
});
|
|
674
|
-
return currentTrip(deps, key);
|
|
971
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
675
972
|
}
|
|
676
973
|
if (name === t.viewTrip)
|
|
677
|
-
return currentTrip(deps, key);
|
|
974
|
+
return currentTrip(deps, key, identity(deps, ctx, key));
|
|
678
975
|
if (name === t.clearTrip) {
|
|
679
976
|
deps.trips.clear(key);
|
|
680
977
|
return {
|
|
681
|
-
structured: { view: 'trip', ...common(deps), lines: [], count: 0, total: null },
|
|
978
|
+
structured: { view: 'trip', ...common(deps, key), lines: [], count: 0, total: null },
|
|
682
979
|
text: 'The trip is empty.',
|
|
683
980
|
};
|
|
684
981
|
}
|
|
@@ -686,11 +983,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
686
983
|
const lines = deps.trips.read(key);
|
|
687
984
|
if (lines.length === 0) {
|
|
688
985
|
return {
|
|
689
|
-
structured: { view: 'empty-trip', ...common(deps) },
|
|
986
|
+
structured: { view: 'empty-trip', ...common(deps, key) },
|
|
690
987
|
text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
|
|
691
988
|
};
|
|
692
989
|
}
|
|
693
990
|
const guest = { name: str(args.name), email: str(args.email) };
|
|
991
|
+
const missing = [!guest.name && 'a name', !guest.email.includes('@') && 'an email'].filter((x) => typeof x === 'string');
|
|
992
|
+
if (missing.length) {
|
|
993
|
+
return {
|
|
994
|
+
structured: { view: 'needs-guest', ...common(deps, key), missing },
|
|
995
|
+
text: `The booking needs ${missing.join(' and ')}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a name or email you know from anywhere else.`,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
694
998
|
try {
|
|
695
999
|
const booking = await deps.backend.requestBooking({ lines }, {
|
|
696
1000
|
...guest,
|
|
@@ -701,7 +1005,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
701
1005
|
return {
|
|
702
1006
|
structured: {
|
|
703
1007
|
view: 'booking',
|
|
704
|
-
...common(deps),
|
|
1008
|
+
...common(deps, key),
|
|
705
1009
|
booking: { ...booking, quote: quoteForCard(booking.quote) },
|
|
706
1010
|
paid: false,
|
|
707
1011
|
holdUntil: booking.holdUntil,
|
|
@@ -720,7 +1024,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
720
1024
|
return {
|
|
721
1025
|
structured: {
|
|
722
1026
|
view: 'error',
|
|
723
|
-
...common(deps),
|
|
1027
|
+
...common(deps, key),
|
|
724
1028
|
error: 'not-bookable',
|
|
725
1029
|
detail: err.message,
|
|
726
1030
|
},
|
|
@@ -729,11 +1033,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
729
1033
|
}
|
|
730
1034
|
}
|
|
731
1035
|
if (name === t.listBookings) {
|
|
732
|
-
const bookings = await deps.backend.listBookings(identity(ctx, args), typeof args.limit === 'number' ? args.limit : undefined);
|
|
1036
|
+
const bookings = await deps.backend.listBookings(identity(deps, ctx, key, args), typeof args.limit === 'number' ? args.limit : undefined);
|
|
733
1037
|
return {
|
|
734
1038
|
structured: {
|
|
735
1039
|
view: 'bookings',
|
|
736
|
-
...common(deps),
|
|
1040
|
+
...common(deps, key),
|
|
737
1041
|
bookings: bookings.map((b) => ({ ...b, quote: quoteForCard(b.quote) })),
|
|
738
1042
|
count: bookings.length,
|
|
739
1043
|
},
|
|
@@ -744,18 +1048,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
744
1048
|
}
|
|
745
1049
|
if (name === t.previewCancellation || name === t.cancelBooking) {
|
|
746
1050
|
const ref = str(args.booking);
|
|
747
|
-
const who = identity(ctx, args);
|
|
1051
|
+
const who = identity(deps, ctx, key, args);
|
|
748
1052
|
const preview = await deps.backend.previewCancellation(ref, who);
|
|
749
1053
|
if (!preview) {
|
|
750
1054
|
return {
|
|
751
|
-
structured: { view: 'booking-not-found', ...common(deps), query: ref },
|
|
1055
|
+
structured: { view: 'booking-not-found', ...common(deps, key), query: ref },
|
|
752
1056
|
text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
753
1057
|
};
|
|
754
1058
|
}
|
|
755
1059
|
const asPreview = {
|
|
756
1060
|
structured: {
|
|
757
1061
|
view: 'cancellation-preview',
|
|
758
|
-
...common(deps),
|
|
1062
|
+
...common(deps, key),
|
|
759
1063
|
booking: { id: preview.bookingId, confirmationCode: preview.confirmationCode },
|
|
760
1064
|
fee: formatMoney(preview.fee),
|
|
761
1065
|
refund: formatMoney(preview.refund),
|
|
@@ -787,7 +1091,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
787
1091
|
return {
|
|
788
1092
|
structured: {
|
|
789
1093
|
view: 'cancelled',
|
|
790
|
-
...common(deps),
|
|
1094
|
+
...common(deps, key),
|
|
791
1095
|
booking: { ...cancelled, quote: quoteForCard(cancelled.quote) },
|
|
792
1096
|
fee: formatMoney(preview.fee),
|
|
793
1097
|
refund: formatMoney(preview.refund),
|
|
@@ -797,12 +1101,122 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
797
1101
|
text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
|
|
798
1102
|
};
|
|
799
1103
|
}
|
|
1104
|
+
if (name === t.compare) {
|
|
1105
|
+
const asks = Array.isArray(args.entries) ? args.entries : [];
|
|
1106
|
+
const comparison = await compareHotels(deps.backend, asks.map((e) => ({
|
|
1107
|
+
hotel: str(e.hotel),
|
|
1108
|
+
bestFor: typeof e.bestFor === 'string' ? e.bestFor : undefined,
|
|
1109
|
+
pros: e.pros,
|
|
1110
|
+
cons: e.cons,
|
|
1111
|
+
})), {
|
|
1112
|
+
stay: asStay(args),
|
|
1113
|
+
recommended: str(args.recommended) || undefined,
|
|
1114
|
+
title: str(args.title) || undefined,
|
|
1115
|
+
});
|
|
1116
|
+
if (comparison.columns.length === 0) {
|
|
1117
|
+
return {
|
|
1118
|
+
structured: {
|
|
1119
|
+
view: 'nothing-to-compare',
|
|
1120
|
+
...common(deps, key),
|
|
1121
|
+
dropped: comparison.dropped,
|
|
1122
|
+
},
|
|
1123
|
+
text: `None of those are ${deps.vocab.brand} ${deps.vocab.nounPlural}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
structured: {
|
|
1128
|
+
view: 'comparison',
|
|
1129
|
+
...common(deps, key),
|
|
1130
|
+
...comparison,
|
|
1131
|
+
columns: comparison.columns.map((c) => ({
|
|
1132
|
+
...c,
|
|
1133
|
+
hotel: { ...c.hotel, fromNightly: undefined },
|
|
1134
|
+
})),
|
|
1135
|
+
},
|
|
1136
|
+
text: [
|
|
1137
|
+
// The card carries the table; the text carries only what the model
|
|
1138
|
+
// could not see before it asked - what was dropped, and why.
|
|
1139
|
+
`${comparison.columns.length} side by side${comparison.stay ? ` for ${comparison.stay.checkIn} to ${comparison.stay.checkOut}` : ''}.`,
|
|
1140
|
+
...comparison.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
1141
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1142
|
+
]
|
|
1143
|
+
.filter(Boolean)
|
|
1144
|
+
.join('\n'),
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
if (name === t.itinerary) {
|
|
1148
|
+
const days = Array.isArray(args.days) ? args.days : [];
|
|
1149
|
+
const plan = await buildItinerary(deps.backend, days.map((d) => ({
|
|
1150
|
+
label: str(d.label),
|
|
1151
|
+
note: typeof d.note === 'string' ? d.note : undefined,
|
|
1152
|
+
items: d.items ?? [],
|
|
1153
|
+
})), { title: str(args.title), stay: asStay(args), trip: deps.trips.read(key) });
|
|
1154
|
+
if (plan.days.length === 0) {
|
|
1155
|
+
return {
|
|
1156
|
+
structured: { view: 'empty-itinerary', ...common(deps, key) },
|
|
1157
|
+
text: 'An itinerary needs at least one day with a label.',
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
// ⚠️ THE SENTENCE THE PLAN MUST NOT BE ALLOWED TO SKIP. A four-day plan
|
|
1161
|
+
// with one room actually added looks exactly like a booking unless
|
|
1162
|
+
// somebody says otherwise, and this counts it rather than describing it.
|
|
1163
|
+
const gap = plan.planned === 0
|
|
1164
|
+
? 'Nothing on this plan is in the trip yet.'
|
|
1165
|
+
: plan.inTrip === plan.planned
|
|
1166
|
+
? `All ${plan.planned} are in the trip.`
|
|
1167
|
+
: `${plan.inTrip} of ${plan.planned} are in the trip; the rest are a plan, not a booking.`;
|
|
1168
|
+
return {
|
|
1169
|
+
structured: { view: 'itinerary', ...common(deps, key), ...plan, gap },
|
|
1170
|
+
text: [
|
|
1171
|
+
`**${plan.title}** · ${plan.days.length} ${plan.days.length === 1 ? 'day' : 'days'}. ${gap}`,
|
|
1172
|
+
...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
|
|
1173
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1174
|
+
]
|
|
1175
|
+
.filter(Boolean)
|
|
1176
|
+
.join('\n'),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
if (name === t.signIn) {
|
|
1180
|
+
const number = str(args.number);
|
|
1181
|
+
const surname = str(args.surname);
|
|
1182
|
+
const member = await deps.backend.signInMember(number, surname);
|
|
1183
|
+
if (!member) {
|
|
1184
|
+
// ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
|
|
1185
|
+
// enough, have the discount" - is inventing a membership, and the guest
|
|
1186
|
+
// would find out at the desk.
|
|
1187
|
+
return {
|
|
1188
|
+
structured: { view: 'member-not-found', ...common(deps, key), query: number },
|
|
1189
|
+
text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - this connector never asks for a password.`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
deps.members.set(key, member);
|
|
1193
|
+
return {
|
|
1194
|
+
structured: { view: 'member', ...common(deps, key), member },
|
|
1195
|
+
text: [
|
|
1196
|
+
`Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
|
|
1197
|
+
`Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
|
|
1198
|
+
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1199
|
+
]
|
|
1200
|
+
.filter(Boolean)
|
|
1201
|
+
.join(' '),
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
if (name === t.signOut) {
|
|
1205
|
+
const was = deps.members.read(key);
|
|
1206
|
+
deps.members.clear(key);
|
|
1207
|
+
return {
|
|
1208
|
+
structured: { view: 'signed-out', ...common(deps, key) },
|
|
1209
|
+
text: was
|
|
1210
|
+
? `Signed out of ${was.programme}. Rates are the public ones again; the trip and any bookings are untouched.`
|
|
1211
|
+
: 'Nobody was signed in.',
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
800
1214
|
if (name === t.policies) {
|
|
801
1215
|
const wanted = str(args.hotel);
|
|
802
1216
|
const detail = await deps.backend.getHotel(wanted);
|
|
803
1217
|
if (!detail) {
|
|
804
1218
|
return {
|
|
805
|
-
structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
|
|
1219
|
+
structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
|
|
806
1220
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
807
1221
|
};
|
|
808
1222
|
}
|
|
@@ -814,7 +1228,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
814
1228
|
return {
|
|
815
1229
|
structured: {
|
|
816
1230
|
view: 'policy-missing',
|
|
817
|
-
...common(deps),
|
|
1231
|
+
...common(deps, key),
|
|
818
1232
|
hotel: detail.hotel,
|
|
819
1233
|
topic,
|
|
820
1234
|
topics: policies.map((p) => ({ topic: p.topic, title: p.title })),
|
|
@@ -825,11 +1239,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
825
1239
|
text: `${detail.hotel.name} does not publish anything about that here.${detail.hotel.url ? ` Its own page: ${detail.hotel.url}` : ''}`,
|
|
826
1240
|
};
|
|
827
1241
|
}
|
|
828
|
-
const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX);
|
|
1242
|
+
const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX, deps.vocab.demo);
|
|
829
1243
|
return {
|
|
830
1244
|
structured: {
|
|
831
1245
|
view: 'policy',
|
|
832
|
-
...common(deps),
|
|
1246
|
+
...common(deps, key),
|
|
833
1247
|
hotel: detail.hotel,
|
|
834
1248
|
topic: one.topic,
|
|
835
1249
|
title: one.title,
|
|
@@ -840,12 +1254,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
840
1254
|
};
|
|
841
1255
|
}
|
|
842
1256
|
const all = policies
|
|
843
|
-
.map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX).fenced)
|
|
1257
|
+
.map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX, deps.vocab.demo).fenced)
|
|
844
1258
|
.join('\n\n');
|
|
845
1259
|
return {
|
|
846
1260
|
structured: {
|
|
847
1261
|
view: 'policies',
|
|
848
|
-
...common(deps),
|
|
1262
|
+
...common(deps, key),
|
|
849
1263
|
hotel: detail.hotel,
|
|
850
1264
|
topics: policies.map((p) => ({
|
|
851
1265
|
topic: p.topic,
|