PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 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 All 34 releases
fluent-booking / app / Modules / MCP / Tools / ContextTools.php

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

404 lines 14.2 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: `get-booking-context` is the "call this first" tool. One call,
19 * no parameters, tells the agent who it is, what it may do, the site's time
20 * conventions, headline counts and every valid enum, so other schemas don't
21 * have to restate them.
22 *
23 * Reference lists are inlined only while small; past the limit they become a
24 * count plus a pointer, so the payload doesn't grow with the site.
25 */
26 class ContextTools
27 {
28 const CACHE_TTL = 60;
29
30 const CACHE_PREFIX = 'fluent_booking_mcp_context_';
31
32 /**
33 * Above this many rows a reference list is summarised rather than inlined.
34 *
35 * Sized to the ≤1,200 token budget in docs/mcp-server-spec.md §10: base
36 * payload ~1,770 bytes, plus 12 calendar rows (~55 bytes) and 12 event-type
37 * rows (~115 bytes) is ~3,800 bytes ≈ 1,090 tokens. Redo the sum before raising it.
38 */
39 const INLINE_LIST_LIMIT = 12;
40
41 /**
42 * Statuses the `status` column holds that Booking::getBookingStatus()'s
43 * label map doesn't list. A value missing from the input_schema enum makes
44 * those bookings unreachable, since the call is rejected outright.
45 *
46 * - reserved: written at checkout for payment-pending bookings.
47 * - no_show: settable via SchedulesController::patchBooking().
48 * - approved: TimeSlotService::getBookedSlots() treats it as occupying a slot.
49 *
50 * Keep this in step with the writers, not with the label map.
51 */
52 const PERSISTED_ONLY_STATUSES = ['reserved', 'no_show', 'approved'];
53
54 /**
55 * Statuses Booking::getBookingStatus() has a label for. Its map is private,
56 * so it's mirrored here rather than read by reflection.
57 */
58 const LABELLED_STATUSES = ['scheduled', 'rescheduled', 'completed', 'pending', 'cancelled', 'rejected'];
59
60 /**
61 * Every value the `status` column can hold, labelled or not.
62 *
63 * @return array
64 */
65 public static function bookingStatuses()
66 {
67 return array_values(array_unique(array_merge(self::LABELLED_STATUSES, self::PERSISTED_ONLY_STATUSES)));
68 }
69
70 /**
71 * The computed period buckets list-bookings accepts: the helper's list
72 * (so filter-added buckets show up), plus the two that
73 * Booking::scopeApplyComputedStatus() honours but the admin dropdown omits.
74 *
75 * @return array
76 */
77 public static function bookingPeriods()
78 {
79 $periods = array_keys((array) Helper::getBookingPeriodOptions());
80
81 return array_values(array_unique(array_merge($periods, ['no_show', 'latest_bookings'])));
82 }
83
84 /**
85 * Event-type discriminators on both fcal_calendar_slots.event_type and
86 * fcal_bookings.event_type.
87 *
88 * @return array
89 */
90 public static function eventTypes()
91 {
92 return CalendarSlot::getEventTypes();
93 }
94
95 /**
96 * Every enum the agent is told to trust. Shared with the tool schemas so
97 * the two can't disagree.
98 *
99 * @return array
100 */
101 public static function enums()
102 {
103 return [
104 'booking_statuses' => self::bookingStatuses(),
105 'booking_periods' => self::bookingPeriods(),
106 'event_types' => self::eventTypes(),
107 'calendar_types' => ['simple', 'team', 'event'],
108 'payment_statuses' => ['pending', 'paid', 'failed', 'refunded', 'partially-paid', 'partially-refunded'],
109 ];
110 }
111
112 /**
113 * @return array
114 */
115 public static function definitions()
116 {
117 return [
118 'fluent-booking/get-booking-context' => [
119 'label' => __('Get booking context', 'fluent-booking'),
120 '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'),
121 'input_schema' => [
122 'type' => 'object',
123 // stdClass, not []: an empty array serialises as a JSON
124 // array and clients reject `"properties": []`.
125 'properties' => new \stdClass(),
126 ],
127 'annotations' => [
128 'title' => __('Get booking context', 'fluent-booking'),
129 'readonly' => true,
130 'idempotent' => true,
131 ],
132 'permission_callback' => [PermissionGate::class, 'readGate'],
133 'execute_callback' => [self::class, 'getContext'],
134 ],
135 ];
136 }
137
138 /**
139 * Build (or serve from cache) the context payload. Cached per user, since
140 * it holds the caller's permissions and permission-scoped counts.
141 *
142 * @param array $params unused
143 * @return array
144 */
145 public static function getContext($params = [])
146 {
147 $cacheKey = self::cacheKey();
148
149 $cached = get_transient($cacheKey);
150
151 if (is_array($cached)) {
152 return $cached;
153 }
154
155 $timezone = DateTimeHelper::getTimeZone();
156 $settings = Helper::getGlobalSettings();
157
158 $payload = MCPHelper::success([
159 'you' => self::identity($timezone),
160 'site' => self::site($timezone, $settings),
161 'counts' => self::counts(),
162 'enums' => self::enums(),
163 'reference' => self::referenceLists(),
164 'terminology' => self::terminology(),
165 ], [
166 'timezone' => $timezone,
167 'scope' => PermissionGate::currentScope(),
168 ], __('Use the enum values above verbatim in filters. Times you send are treated as UTC unless you pass an explicit timezone.', 'fluent-booking'));
169
170 set_transient($cacheKey, $payload, self::CACHE_TTL);
171
172 return $payload;
173 }
174
175 /**
176 * Invalidate every user's cached context. The cache is per user and the
177 * hooks fire as the editor, so bump a version in the key rather than
178 * deleting one user's entry.
179 */
180 public static function invalidateCache()
181 {
182 $option = self::CACHE_PREFIX . 'version';
183
184 // Not autoloaded: only MCP reads it, and autoloading would flush
185 // alloptions on every calendar write.
186 update_option($option, (int) get_option($option, 0) + 1, false);
187 }
188
189 /**
190 * Per-user, per-site, per-version cache key. The blog id is included
191 * because an object cache isn't guaranteed to isolate keys per site.
192 *
193 * @return string
194 */
195 private static function cacheKey()
196 {
197 $version = (int) get_option(self::CACHE_PREFIX . 'version', 0);
198
199 return self::CACHE_PREFIX . get_current_blog_id() . '_' . get_current_user_id() . '_' . $version;
200 }
201
202 /**
203 * Who the agent is acting as. Permission keys are echoed verbatim so a
204 * permission_denied can be traced to the missing grant.
205 *
206 * @param string $timezone
207 * @return array
208 */
209 private static function identity($timezone)
210 {
211 $user = wp_get_current_user();
212
213 $permissions = PermissionManager::getUserPermissions();
214
215 return [
216 'user_id' => (int) get_current_user_id(),
217 'display_name' => $user ? $user->display_name : '',
218 'permissions' => array_values((array) $permissions),
219 'can_see_all_bookings' => PermissionGate::canSeeAllBookings(),
220 'can_manage_all_data' => PermissionManager::userCan('manage_all_data'),
221 'scope' => PermissionGate::currentScope(),
222 'timezone' => $timezone,
223 ];
224 }
225
226 /**
227 * Site conventions the agent would otherwise guess: timezone, current time,
228 * week start and clock format.
229 *
230 * @param string $timezone
231 * @param array $settings
232 * @return array
233 */
234 private static function site($timezone, $settings)
235 {
236 $nowUtc = gmdate('Y-m-d H:i:s'); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
237
238 return array_merge(
239 [
240 'timezone' => $timezone,
241 'week_starts' => Arr::get($settings, 'administration.start_day', 'sun'),
242 'time_format' => Arr::get($settings, 'time_format', '12'),
243 'currency' => Arr::get($settings, 'payments.currency', 'USD'),
244 'locale' => get_locale(),
245 'version' => defined('FLUENT_BOOKING_VERSION') ? FLUENT_BOOKING_VERSION : '',
246 'pro_active' => defined('FLUENT_BOOKING_PRO_DIR_FILE'),
247 'toolsets' => PermissionGate::enabledToolsets(),
248 ],
249 MCPHelper::timePair($nowUtc, $timezone, 'now')
250 );
251 }
252
253 /**
254 * Headline counts, scoped exactly like the list tools' queries.
255 *
256 * @return array
257 */
258 private static function counts()
259 {
260 $seesAll = PermissionGate::canSeeAllBookings();
261 $userId = get_current_user_id();
262
263 $bookingQuery = Booking::query();
264
265 if (!$seesAll) {
266 // Matches BookingTools::buildQuery(). A bare host_user_id filter
267 // would miss bookings where the caller is a secondary host.
268 $bookingQuery->whereHostAccess($userId);
269 }
270
271 $upcoming = (clone $bookingQuery)
272 ->where('end_time', '>=', gmdate('Y-m-d H:i:s')) // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
273 ->where('status', 'scheduled')
274 ->count();
275
276 $pending = (clone $bookingQuery)->whereIn('status', ['pending', 'reserved'])->count();
277
278 return [
279 'calendars' => self::visibleCalendarQuery()->count(),
280 'event_types' => self::visibleEventTypeQuery()->count(),
281 'upcoming_bookings' => (int) $upcoming,
282 'pending_bookings' => (int) $pending,
283 ];
284 }
285
286 /**
287 * @return array
288 */
289 private static function referenceLists()
290 {
291 return [
292 'calendars' => self::inlineList(
293 self::visibleCalendarQuery(),
294 // list-reference-data is in the `scheduling` toolset; on a
295 // core-only site there's nothing to point at.
296 PermissionGate::isToolsetEnabled(PermissionGate::TOOLSET_SCHEDULING)
297 ? 'fluent-booking/list-reference-data'
298 : '',
299 function ($calendar) {
300 return [
301 'id' => (int) $calendar->id,
302 'title' => $calendar->title,
303 'type' => $calendar->type,
304 'user_id' => (int) $calendar->user_id,
305 ];
306 }
307 ),
308 'event_types' => self::inlineList(
309 self::visibleEventTypeQuery(),
310 // Not list-reference-data: it has no `event_types` kind.
311 'fluent-booking/get-event-types',
312 function ($slot) {
313 return [
314 'id' => (int) $slot->id,
315 'calendar_id' => (int) $slot->calendar_id,
316 'title' => $slot->title,
317 'duration' => (int) $slot->duration,
318 'event_type' => $slot->event_type,
319 'status' => $slot->status,
320 ];
321 }
322 ),
323 ];
324 }
325
326 /**
327 * Inline a list, or summarise it past INLINE_LIST_LIMIT.
328 *
329 * @param object $query a model query, already permission-scoped
330 * @param string $getWith the tool that returns this list in full; '' when
331 * no enabled toolset exposes one
332 * @param callable $projector row => compact array
333 * @return array
334 */
335 private static function inlineList($query, $getWith, $projector)
336 {
337 $total = (clone $query)->count();
338
339 if ($total > self::INLINE_LIST_LIMIT) {
340 $summary = [
341 'total' => (int) $total,
342 'inlined' => false,
343 ];
344
345 if ($getWith) {
346 $summary['get_with'] = $getWith;
347 }
348
349 return $summary;
350 }
351
352 $items = $query->get();
353
354 $mapped = [];
355
356 foreach ($items as $item) {
357 $mapped[] = call_user_func($projector, $item);
358 }
359
360 return [
361 'total' => (int) $total,
362 'inlined' => true,
363 'items' => $mapped,
364 ];
365 }
366
367 /**
368 * Calendars this caller may read, via the shared visibility helper so
369 * this and the list tools agree.
370 *
371 * @return object
372 */
373 private static function visibleCalendarQuery()
374 {
375 return PermissionGate::scopeToReadableCalendars(Calendar::query(), 'id');
376 }
377
378 /**
379 * Event types on the calendars this caller may read.
380 *
381 * @return object
382 */
383 private static function visibleEventTypeQuery()
384 {
385 return PermissionGate::scopeToReadableCalendars(CalendarSlot::query(), 'calendar_id');
386 }
387
388 /**
389 * FluentBooking's nouns mapped to the ones an agent likely arrives with
390 * (e.g. Cal.com's "event type"), stated once instead of in every tool.
391 *
392 * @return array
393 */
394 private static function terminology()
395 {
396 return [
397 'event_type' => __('A bookable meeting definition (duration, location, questions). Stored as a calendar slot.', 'fluent-booking'),
398 'calendar' => __('A host (type "simple"), a team (type "team"), or a one-off event calendar (type "event").', 'fluent-booking'),
399 'booking' => __('One scheduled appointment. Group bookings share a group_id.', 'fluent-booking'),
400 'availability' => __('A named weekly schedule plus date overrides, reusable across event types.', 'fluent-booking'),
401 ];
402 }
403 }
404