| 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 |
|