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