PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / Tools / ContextTools.php

ContextTools.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Modules/MCP/Tools/ContextTools.php

465 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Tools;
4
5 use FluentBooking\App\Models\Booking;
6 use FluentBooking\App\Models\Calendar;
7 use FluentBooking\App\Models\CalendarSlot;
8 use FluentBooking\App\Modules\MCP\Support\MCPHelper;
9 use FluentBooking\App\Modules\MCP\Support\PermissionGate;
10 use FluentBooking\App\Services\DateTimeHelper;
11 use FluentBooking\App\Services\Helper;
12 use FluentBooking\App\Services\PermissionManager;
13 use FluentBooking\Framework\Support\Arr;
14
15 defined('ABSPATH') || exit;
16
17 /**
18 * Discovery — the agent's entry point into a FluentBooking site.
19 *
20 * `get-booking-context` is the documented "call this first" tool. One call tells
21 * the agent who it is, what it may do, the site's time conventions, headline
22 * counts, and every valid enum — so it never has to guess a status string or a
23 * timezone. It takes no parameters at all: discovery should have zero friction,
24 * and a no-argument schema is also the cheapest schema there is.
25 *
26 * It earns its ~350 tokens of resident context several times over. Without it,
27 * every other tool's schema would have to restate the status and event-type
28 * enums inline, and the agent would still guess wrong about week start and
29 * timezone.
30 *
31 * Small reference lists (hosts, calendars, event types) are inlined only while
32 * they stay small. Past the threshold the payload reports counts and points at
33 * `list-reference-data` instead — a context tool that grows with the size of
34 * the site is a context tool that eventually breaks the session it was meant to
35 * bootstrap.
36 */
37 class ContextTools
38 {
39 const CACHE_TTL = 60;
40
41 const CACHE_PREFIX = 'fluent_booking_mcp_context_';
42
43 /**
44 * Above this many rows a reference list is summarised rather than inlined.
45 *
46 * Derived from the response budget in docs/mcp-server-spec.md §10 (≤1,200
47 * tokens for this tool), not picked by feel. Measured against a real site:
48 * the payload without reference lists is ~1,770 bytes, a calendar row ~55
49 * and an event-type row ~115. Twelve of each is 12 × 55 + 12 × 115 = 2,040
50 * bytes, for a worst case of ~3,800 bytes ≈ 1,090 tokens. Raising this
51 * without re-doing that arithmetic breaks the budget the whole design rests
52 * on — this tool is called at the start of every session.
53 */
54 const INLINE_LIST_LIMIT = 12;
55
56 /**
57 * Booking statuses the `status` column genuinely holds that
58 * Booking::getBookingStatus()'s label map does NOT list.
59 *
60 * An enum is wrong in two directions and only one of them is loud. Listing a
61 * value the column can never hold gives the agent a filter that silently
62 * returns zero rows. OMITTING a value the column does hold is worse: those
63 * bookings become unreachable, and because the value is absent from the
64 * input_schema enum the call is rejected outright, so the agent cannot even
65 * discover that the rows exist.
66 *
67 * Both of these are written by real code paths:
68 * - reserved: written during checkout for payment-pending bookings and
69 * queried by SchedulesController::addCountsForFirstPage().
70 * - no_show: settable through SchedulesController::patchBooking()'s status
71 * whitelist and counted by the same method.
72 * - approved: TimeSlotService::getBookedSlots() treats it as occupying a
73 * slot, so rows holding it are real enough to remove
74 * availability — and were previously unreachable by any filter.
75 *
76 * Keep this list in step with the writers, not with the label map.
77 */
78 const PERSISTED_ONLY_STATUSES = ['reserved', 'no_show', 'approved'];
79
80 /**
81 * Statuses Booking::getBookingStatus() has a label for. That method keeps
82 * its map private, so the list is mirrored here rather than read out of it —
83 * and the mirror is deliberate: adding a status there without adding it here
84 * only costs the agent a filter, whereas reflecting into a private array
85 * would break silently on any refactor.
86 */
87 const LABELLED_STATUSES = ['scheduled', 'rescheduled', 'completed', 'pending', 'cancelled', 'rejected'];
88
89 /**
90 * Every value the `status` column can hold, labelled or not.
91 *
92 * @return array
93 */
94 public static function bookingStatuses()
95 {
96 return array_values(array_unique(array_merge(self::LABELLED_STATUSES, self::PERSISTED_ONLY_STATUSES)));
97 }
98
99 /**
100 * The computed period buckets list-bookings accepts.
101 *
102 * Read from the canonical helper so a bucket added by a filter shows up
103 * automatically, then unioned with the two that
104 * Booking::scopeApplyComputedStatus() honours but the admin's filter
105 * dropdown never renders. A period the scope supports but the enum omits is
106 * a filter the agent cannot reach; one the enum lists but the scope ignores
107 * silently returns the unfiltered set. Both directions matter.
108 *
109 * @return array
110 */
111 public static function bookingPeriods()
112 {
113 $periods = array_keys((array) Helper::getBookingPeriodOptions());
114
115 return array_values(array_unique(array_merge($periods, ['no_show', 'latest_bookings'])));
116 }
117
118 /**
119 * Event-type discriminators on both fcal_calendar_slots.event_type and
120 * fcal_bookings.event_type.
121 *
122 * @return array
123 */
124 public static function eventTypes()
125 {
126 return ['single', 'group', 'round_robin', 'collective', 'single_event', 'group_event'];
127 }
128
129 /**
130 * Every enum the agent is told to trust. Shared with the tool schemas, so a
131 * value the agent is offered in an input_schema and a value this payload
132 * advertises can never disagree.
133 *
134 * @return array
135 */
136 public static function enums()
137 {
138 return [
139 'booking_statuses' => self::bookingStatuses(),
140 'booking_periods' => self::bookingPeriods(),
141 'event_types' => self::eventTypes(),
142 'calendar_types' => ['simple', 'team', 'event'],
143 'payment_statuses' => ['pending', 'paid', 'failed', 'refunded', 'partially-paid', 'partially-refunded'],
144 ];
145 }
146
147 /**
148 * The tool definitions this class owns.
149 *
150 * @return array
151 */
152 public static function definitions()
153 {
154 return [
155 'fluent-booking/get-booking-context' => [
156 'label' => __('Get booking context', 'fluent-booking'),
157 'description' => __('Call this first. Returns who you are, what you may do, the site timezone and current time, valid enum values for every filter, headline counts, and small reference lists of hosts, calendars and event types.', 'fluent-booking'),
158 'input_schema' => [
159 'type' => 'object',
160 // stdClass, not [] — an empty PHP array serialises as a JSON
161 // array and clients reject `"properties": []`.
162 'properties' => new \stdClass(),
163 ],
164 'annotations' => [
165 'title' => __('Get booking context', 'fluent-booking'),
166 'readonly' => true,
167 'idempotent' => true,
168 ],
169 'permission_callback' => [PermissionGate::class, 'readGate'],
170 'execute_callback' => [self::class, 'getContext'],
171 ],
172 ];
173 }
174
175 /**
176 * Build (or serve from cache) the context payload.
177 *
178 * Cached per user, never globally: the payload states the caller's
179 * permission set and permission-scoped counts, so a shared cache entry would
180 * hand one host another host's view of the site.
181 *
182 * @param array $params unused; the tool takes none
183 * @return array
184 */
185 public static function getContext($params = [])
186 {
187 $cacheKey = self::cacheKey();
188
189 $cached = get_transient($cacheKey);
190
191 if (is_array($cached)) {
192 return $cached;
193 }
194
195 $timezone = DateTimeHelper::getTimeZone();
196 $settings = Helper::getGlobalSettings();
197
198 $payload = MCPHelper::success([
199 'you' => self::identity($timezone),
200 'site' => self::site($timezone, $settings),
201 'counts' => self::counts(),
202 'enums' => self::enums(),
203 'reference' => self::referenceLists(),
204 'terminology' => self::terminology(),
205 ], [
206 'timezone' => $timezone,
207 'scope' => PermissionGate::currentScope(),
208 ], __('Use the enum values above verbatim in filters. Times you send are treated as UTC unless you pass an explicit timezone.', 'fluent-booking'));
209
210 set_transient($cacheKey, $payload, self::CACHE_TTL);
211
212 return $payload;
213 }
214
215 /**
216 * Invalidate every user's cached context.
217 *
218 * Bumping a shared version counter rather than deleting a key: the cache is
219 * per-user, and these hooks fire as whoever made the edit, so deleting
220 * "the" key only ever cleared the editor's own copy and left every other
221 * operator reading stale reference lists until the TTL expired. The version
222 * is part of the key, so one write retires all of them at once.
223 */
224 public static function invalidateCache()
225 {
226 $option = self::CACHE_PREFIX . 'version';
227
228 // Not autoloaded. Only MCP requests read it, and autoloading meant
229 // every calendar write flushed the site's alloptions cache.
230 update_option($option, (int) get_option($option, 0) + 1, false);
231 }
232
233 /**
234 * Per-user, per-site, per-version cache key. `get_current_blog_id()` is
235 * included because an object-cache backend fronting transients is not
236 * guaranteed to isolate keys per site on multisite.
237 *
238 * @return string
239 */
240 private static function cacheKey()
241 {
242 $version = (int) get_option(self::CACHE_PREFIX . 'version', 0);
243
244 return self::CACHE_PREFIX . get_current_blog_id() . '_' . get_current_user_id() . '_' . $version;
245 }
246
247 /**
248 * Who the agent is acting as, and what that account may do. The permission
249 * keys are echoed verbatim so an agent that hits a permission_denied can
250 * tell the user exactly which grant is missing.
251 *
252 * @param string $timezone
253 * @return array
254 */
255 private static function identity($timezone)
256 {
257 $user = wp_get_current_user();
258
259 $permissions = PermissionManager::getUserPermissions();
260
261 return [
262 'user_id' => (int) get_current_user_id(),
263 'display_name' => $user ? $user->display_name : '',
264 'permissions' => array_values((array) $permissions),
265 'can_see_all_bookings' => PermissionGate::canSeeAllBookings(),
266 'can_manage_all_data' => PermissionManager::userCan('manage_all_data'),
267 'scope' => PermissionGate::currentScope(),
268 'timezone' => $timezone,
269 ];
270 }
271
272 /**
273 * Site conventions the agent would otherwise guess wrong: the zone, the
274 * current instant in both UTC and local form, which day the week starts on,
275 * and the clock format the operator reads.
276 *
277 * @param string $timezone
278 * @param array $settings
279 * @return array
280 */
281 private static function site($timezone, $settings)
282 {
283 $nowUtc = gmdate('Y-m-d H:i:s'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
284
285 return array_merge(
286 [
287 'timezone' => $timezone,
288 'week_starts' => Arr::get($settings, 'administration.start_day', 'sun'),
289 'time_format' => Arr::get($settings, 'time_format', '12'),
290 'currency' => Arr::get($settings, 'payments.currency', 'USD'),
291 'locale' => get_locale(),
292 'version' => defined('FLUENT_BOOKING_VERSION') ? FLUENT_BOOKING_VERSION : '',
293 'pro_active' => defined('FLUENT_BOOKING_PRO_DIR_FILE'),
294 'toolsets' => PermissionGate::enabledToolsets(),
295 ],
296 MCPHelper::timePair($nowUtc, $timezone, 'now')
297 );
298 }
299
300 /**
301 * Headline counts, scoped exactly the way the list tools scope their
302 * queries. A count built on a wider query than the list it describes is a
303 * disclosure bug, so both go through the same host filter.
304 *
305 * @return array
306 */
307 private static function counts()
308 {
309 $seesAll = PermissionGate::canSeeAllBookings();
310 $userId = get_current_user_id();
311
312 $bookingQuery = Booking::query();
313
314 if (!$seesAll) {
315 // whereHostAccess(), matching BookingTools::buildQuery() and
316 // BookingReportService::scoped(). A bare host_user_id filter is
317 // NARROWER: it misses every booking the caller is a secondary host
318 // on, which is most of a round-robin or collective host's work. The
319 // context payload would report three upcoming bookings and
320 // list-bookings would then return eleven.
321 $bookingQuery->whereHostAccess($userId);
322 }
323
324 $upcoming = (clone $bookingQuery)
325 ->where('end_time', '>=', gmdate('Y-m-d H:i:s')) // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
326 ->where('status', 'scheduled')
327 ->count();
328
329 $pending = (clone $bookingQuery)->whereIn('status', ['pending', 'reserved'])->count();
330
331 return [
332 'calendars' => self::visibleCalendarQuery()->count(),
333 'event_types' => self::visibleEventTypeQuery()->count(),
334 'upcoming_bookings' => (int) $upcoming,
335 'pending_bookings' => (int) $pending,
336 ];
337 }
338
339 /**
340 * Reference lists, inlined only while they are small enough to be free. Past
341 * INLINE_LIST_LIMIT the entry becomes a count plus a pointer, so the context
342 * payload stays flat as a site grows.
343 *
344 * @return array
345 */
346 private static function referenceLists()
347 {
348 return [
349 'calendars' => self::inlineList(
350 self::visibleCalendarQuery(),
351 // list-reference-data is in the `scheduling` toolset: on a
352 // core-only site nothing lists calendars, so point at nothing.
353 PermissionGate::isToolsetEnabled(PermissionGate::TOOLSET_SCHEDULING)
354 ? 'fluent-booking/list-reference-data'
355 : '',
356 function ($calendar) {
357 return [
358 'id' => (int) $calendar->id,
359 'title' => $calendar->title,
360 'type' => $calendar->type,
361 'user_id' => (int) $calendar->user_id,
362 ];
363 }
364 ),
365 'event_types' => self::inlineList(
366 self::visibleEventTypeQuery(),
367 // NOT list-reference-data: it has no `event_types` kind, so
368 // that pointer fails validation. get-event-types is in `core`.
369 'fluent-booking/get-event-types',
370 function ($slot) {
371 return [
372 'id' => (int) $slot->id,
373 'calendar_id' => (int) $slot->calendar_id,
374 'title' => $slot->title,
375 'duration' => (int) $slot->duration,
376 'event_type' => $slot->event_type,
377 'status' => $slot->status,
378 ];
379 }
380 ),
381 ];
382 }
383
384 /**
385 * Inline a list, or summarise it when it is too long to be free.
386 *
387 * @param object $query a model query, already permission-scoped
388 * @param string $getWith the tool that returns this list in full; '' when
389 * no enabled toolset exposes one
390 * @param callable $projector row => compact array
391 * @return array
392 */
393 private static function inlineList($query, $getWith, $projector)
394 {
395 $total = (clone $query)->count();
396
397 if ($total > self::INLINE_LIST_LIMIT) {
398 $summary = [
399 'total' => (int) $total,
400 'inlined' => false,
401 ];
402
403 if ($getWith) {
404 $summary['get_with'] = $getWith;
405 }
406
407 return $summary;
408 }
409
410 $items = $query->get();
411
412 $mapped = [];
413
414 foreach ($items as $item) {
415 $mapped[] = call_user_func($projector, $item);
416 }
417
418 return [
419 'total' => (int) $total,
420 'inlined' => true,
421 'items' => $mapped,
422 ];
423 }
424
425 /**
426 * Calendars this caller may read, through the module's one visibility
427 * helper — so the context payload, `get-event-types` and
428 * `list-reference-data` cannot disagree about what exists.
429 *
430 * @return object
431 */
432 private static function visibleCalendarQuery()
433 {
434 return PermissionGate::scopeToReadableCalendars(Calendar::query(), 'id');
435 }
436
437 /**
438 * Event types on the calendars this caller may read.
439 *
440 * @return object
441 */
442 private static function visibleEventTypeQuery()
443 {
444 return PermissionGate::scopeToReadableCalendars(CalendarSlot::query(), 'calendar_id');
445 }
446
447 /**
448 * FluentBooking's nouns against the ones an agent is most likely to arrive
449 * with. An agent that has read Cal.com's docs will ask for an "event type"
450 * and a "schedule"; telling it the mapping once here is cheaper than every
451 * tool description explaining itself.
452 *
453 * @return array
454 */
455 private static function terminology()
456 {
457 return [
458 'event_type' => __('A bookable meeting definition (duration, location, questions). Stored as a calendar slot.', 'fluent-booking'),
459 'calendar' => __('A host (type "simple"), a team (type "team"), or a one-off event calendar (type "event").', 'fluent-booking'),
460 'booking' => __('One scheduled appointment. Group bookings share a group_id.', 'fluent-booking'),
461 'availability' => __('A named weekly schedule plus date overrides, reusable across event types.', 'fluent-booking'),
462 ];
463 }
464 }
465