PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Modules / MCP / Tools / ContextTools.php

ContextTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.0, at app/Modules/MCP/Tools/ContextTools.php

489 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Tools;
4
5 use FluentCart\App\Models\Order;
6 use FluentCart\App\Models\Customer;
7 use FluentCart\App\Models\Subscription;
8 use FluentCart\App\Modules\MCP\Support\MCPHelper;
9 use FluentCart\App\Modules\MCP\Support\PermissionGate;
10 use FluentCart\App\Services\DateTime\DateTime;
11 use FluentCart\App\Services\Permission\PermissionManager;
12
13 /**
14 * Discovery tools — the agent's entry point into a FluentCart store.
15 *
16 * `get-store-context` is the documented "call this first" tool. One call tells
17 * the agent who it is, what it's allowed to do, the store's money/time
18 * conventions, headline numbers, and every valid enum value — so it never has
19 * to guess a status string or invent a currency format. It's cached (60s) and
20 * invalidated when reference data changes, because it's called every session.
21 *
22 * `list-reference-data` is the on-demand lookup for the heavier reference lists
23 * (coupons, labels, tax/shipping config) the agent only sometimes needs — kept
24 * OUT of the context payload so the first call stays lean.
25 *
26 * Parameter philosophy: get-store-context takes nothing (zero friction, it's
27 * discovery). list-reference-data takes only `kinds[]` — the agent asks for
28 * exactly the lists it needs, and we return only the kinds its role can see.
29 */
30 class ContextTools
31 {
32 const CACHE_TTL = 60;
33
34 const CACHE_PREFIX = 'fluent_cart_mcp_context_';
35
36 // The verified FluentCart domain enums. Hardcoded (with a filter override)
37 // rather than scraped, so the agent always gets the complete valid set even
38 // if a status currently has zero rows.
39 const ENUMS = [
40 'order_statuses' => ['draft', 'pending', 'on-hold', 'processing', 'completed', 'canceled', 'failed', 'refunded', 'partial-refund'],
41 // Kept in sync with Status::getPaymentStatuses(); 'authorized' is a valid
42 // persisted status (card authorized, not yet captured) and must be listed
43 // so clients can filter authorized orders through list-orders.
44 'payment_statuses' => ['paid', 'pending', 'failed', 'refunded', 'partially_refunded', 'partially_paid', 'authorized'],
45 // 'none' = no shipping required (e.g. digital orders); reported when the
46 // stored value is empty. It is read-only — change-order-status won't set it.
47 'shipping_statuses' => ['none', 'unshipped', 'shipped', 'delivered', 'unshippable'],
48 'order_types' => ['payment', 'renewal', 'subscription'],
49 'subscription_statuses' => ['active', 'trialing', 'paused', 'canceled', 'failing', 'expired', 'expiring', 'past_due', 'intended', 'pending', 'completed'],
50 'billing_intervals' => ['daily', 'weekly', 'monthly', 'quarterly', 'half_yearly', 'yearly'],
51 'fulfillment_types' => ['physical', 'digital'],
52 'coupon_types' => ['fixed', 'percentage'],
53 'order_modes' => ['live', 'test'],
54 ];
55
56 // Payment statuses that count as realized revenue. Centralized so every
57 // tool (context, reports, aggregates) agrees on what "paid" means.
58 const PAID_STATUSES = ['paid', 'partially_paid', 'partially_refunded'];
59
60 /**
61 * Ability definitions for this domain. The registrar merges every tool
62 * class's definitions(), so a tool's schema lives next to its code.
63 */
64 public static function definitions()
65 {
66 return [
67 'fluent-cart/get-store-context' => [
68 'label' => __('Get Store Context', 'fluent-cart'),
69 'description' => __('START HERE — call once per session. Returns who you are and your permissions, the store currency/timezone conventions, headline stats, every valid enum value (order/payment/shipping/subscription statuses, intervals, types), and usage guidelines. Use this before any other tool so you never guess a status string or money format.', 'fluent-cart'),
70 'input_schema' => [
71 'type' => 'object',
72 'properties' => new \stdClass(),
73 ],
74 'execute_callback' => [self::class, 'getContext'],
75 'permission_callback' => function () {
76 return PermissionGate::can('dashboard_stats/view') || PermissionGate::canAny(PermissionGate::readRoleCaps());
77 },
78 'annotations' => ['readonly' => true],
79 ],
80
81 'fluent-cart/list-reference-data' => [
82 'label' => __('List Reference Data', 'fluent-cart'),
83 'description' => __('On-demand lookup lists kept out of get-store-context to keep it lean: coupons, labels, gateways, tax_classes, shipping_zones, product_categories. Pass kinds[] with only what you need. Kinds your role cannot see are reported in meta.warnings, not dropped silently.', 'fluent-cart'),
84 'input_schema' => [
85 'type' => 'object',
86 'properties' => [
87 'kinds' => [
88 'type' => 'array',
89 'description' => 'Which reference lists to return.',
90 'items' => ['type' => 'string', 'enum' => ['coupons', 'labels', 'gateways', 'tax_classes', 'shipping_zones', 'product_categories']],
91 ],
92 ],
93 'required' => ['kinds'],
94 ],
95 'execute_callback' => [self::class, 'listReferenceData'],
96 'permission_callback' => function () {
97 return PermissionGate::canAny(PermissionGate::readRoleCaps());
98 },
99 'annotations' => ['readonly' => true],
100 ],
101 ];
102 }
103
104 public static function getContext($params = [])
105 {
106 $userId = get_current_user_id();
107 $cacheKey = self::CACHE_PREFIX . $userId;
108
109 $cached = get_transient($cacheKey);
110 if (is_array($cached)) {
111 return $cached;
112 }
113
114 $context = self::buildContext($userId);
115 set_transient($cacheKey, $context, self::CACHE_TTL);
116
117 return $context;
118 }
119
120 private static function buildContext($userId)
121 {
122 $user = get_user_by('ID', $userId);
123 $isAdmin = $user && user_can($user, 'manage_options');
124
125 $you = [
126 'wp_user_id' => (int) $userId,
127 'name' => $user ? $user->display_name : null,
128 'email' => $user ? $user->user_email : null,
129 'is_admin' => (bool) $isAdmin,
130 'permissions' => array_values((array) PermissionManager::getUserPermissions()),
131 ];
132
133 $store = [
134 'name' => get_bloginfo('name'),
135 'url' => site_url(),
136 'version' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : null,
137 'pro_active' => defined('FLUENT_CART_PRO') || defined('FLUENTCART_PRO_VERSION'),
138 'currency' => MCPHelper::currencyContext(),
139 'timezone' => wp_timezone_string(),
140 'current_time' => MCPHelper::toIso8601(DateTime::gmtNow()),
141 ];
142
143 // Headline stats are dashboard data: gate them on dashboard_stats/view so
144 // a narrow read role can still get context (enums, currency, permissions)
145 // without seeing store-wide revenue/order/customer numbers.
146 $canStats = PermissionGate::can('dashboard_stats/view');
147 $stats = $canStats ? self::buildStats() : null;
148
149 return MCPHelper::envelope(
150 $canStats ? self::summary($stats) : __('Store context loaded.', 'fluent-cart'),
151 [
152 'you' => $you,
153 'store' => $store,
154 'stats' => $stats,
155 'enums' => apply_filters('fluent_cart/mcp_enums', self::ENUMS),
156 'reference_kinds' => self::referenceKinds(),
157 'guidelines' => self::guidelines(),
158 ]
159 );
160 }
161
162 /**
163 * Headline numbers. Each metric is isolated in safeCount/safeSum so one
164 * failing query (e.g. a model that doesn't exist on a given install) yields
165 * null for that stat instead of breaking the whole discovery call.
166 */
167 private static function buildStats()
168 {
169 $since30 = DateTime::gmtNow()->modify('-30 days')->format('Y-m-d H:i:s');
170
171 return [
172 'orders_total' => self::safeCount(function () {
173 return Order::query()->count();
174 }),
175 'orders_last_30d' => self::safeCount(function () use ($since30) {
176 return Order::query()->where('created_at', '>=', $since30)->count();
177 }),
178 'revenue_last_30d' => self::safeMoney(function () use ($since30) {
179 return (int) Order::query()
180 ->whereIn('payment_status', self::PAID_STATUSES)
181 ->where('created_at', '>=', $since30)
182 ->sum('total_paid');
183 }),
184 'customers_total' => self::safeCount(function () {
185 return Customer::query()->count();
186 }),
187 'active_subscriptions' => self::safeCount(function () {
188 return Subscription::query()->where('status', 'active')->count();
189 }),
190 'products_published' => self::safeCount(function () {
191 if (!class_exists('\FluentCart\App\Models\Product')) {
192 return null;
193 }
194 // post_type is pinned by the model's global scope; a literal
195 // here (and the wrong singular one) would match nothing.
196 return \FluentCart\App\Models\Product::query()
197 ->where('post_status', 'publish')
198 ->count();
199 }),
200 ];
201 }
202
203 private static function safeCount(callable $fn)
204 {
205 try {
206 $val = $fn();
207 return $val === null ? null : (int) $val;
208 } catch (\Throwable $e) {
209 return null;
210 }
211 }
212
213 private static function safeMoney(callable $fn)
214 {
215 try {
216 return MCPHelper::money((int) $fn());
217 } catch (\Throwable $e) {
218 return null;
219 }
220 }
221
222 /* translators: %1$s: revenue amount, %2$d: orders in 30 days, %3$d: total customers */
223 private static function summary($stats)
224 {
225 $rev30 = isset($stats['revenue_last_30d']['display']) ? $stats['revenue_last_30d']['display'] : '—';
226 $orders30 = isset($stats['orders_last_30d']) ? (int) $stats['orders_last_30d'] : 0;
227 $customers = isset($stats['customers_total']) ? (int) $stats['customers_total'] : 0;
228
229 return sprintf(
230 /* translators: %1$s: 30-day revenue, %2$d: 30-day order count, %3$d: total customers */
231 __('Store snapshot — last 30 days: %1$s across %2$d orders; %3$d customers total.', 'fluent-cart'),
232 $rev30,
233 $orders30,
234 $customers
235 );
236 }
237
238 /** Tells the agent what `kinds` it can pass to list-reference-data. */
239 private static function referenceKinds()
240 {
241 return ['coupons', 'labels', 'gateways', 'tax_classes', 'shipping_zones', 'product_categories'];
242 }
243
244 private static function guidelines()
245 {
246 $default = 'Call get-store-context once per session, then use search-* tools to find records and get-* tools to load one record fully. '
247 . 'Money is returned as both a number (amount) and a formatted string (display) — quote display, compare amount. '
248 . 'Dates are ISO-8601 UTC; pass a relative range (e.g. last_30_days) or explicit start_date/end_date to report tools. '
249 . 'Use the exact enum values from this payload — never invent a status. '
250 . 'Reports never sum across currencies; filter by one currency if the store has several. '
251 . 'Writes (refund-order, change-subscription-status:cancel) require a dry_run preview first.';
252
253 return apply_filters('fluent_cart/mcp_guidelines', $default);
254 }
255
256 /**
257 * `list-reference-data` — heavier lookup lists, fetched on demand.
258 *
259 * @param array $params { kinds: string[] } — which lists to return. Each
260 * kind is gated by its own capability; kinds the
261 * caller can't see are reported in `skipped`, not
262 * silently dropped, so the agent knows why.
263 */
264 public static function listReferenceData($params = [])
265 {
266 $kinds = isset($params['kinds']) ? (array) $params['kinds'] : [];
267 if (!$kinds) {
268 return MCPHelper::error(
269 'missing_kinds',
270 __('Provide one or more kinds. Valid: coupons, labels, gateways, tax_classes, shipping_zones, product_categories.', 'fluent-cart'),
271 ['valid_kinds' => self::referenceKinds()]
272 );
273 }
274
275 $gate = [
276 'coupons' => 'coupons/view',
277 'labels' => 'labels/view',
278 'gateways' => 'dashboard_stats/view',
279 'tax_classes' => 'store/settings',
280 'shipping_zones' => 'store/settings',
281 'product_categories' => 'products/view',
282 ];
283
284 $data = [];
285 $skipped = [];
286
287 foreach ($kinds as $kind) {
288 if (!isset($gate[$kind])) {
289 $skipped[$kind] = 'unknown_kind';
290 continue;
291 }
292 if (!PermissionGate::can($gate[$kind])) {
293 $skipped[$kind] = 'forbidden: requires ' . $gate[$kind];
294 continue;
295 }
296 $data[$kind] = self::fetchReferenceKind($kind);
297 }
298
299 $meta = $skipped ? ['warnings' => self::skipWarnings($skipped)] : [];
300
301 return MCPHelper::envelope(
302 sprintf(
303 /* translators: %d: number of reference lists returned */
304 _n('Returned %d reference list.', 'Returned %d reference lists.', count($data), 'fluent-cart'),
305 count($data)
306 ),
307 $data,
308 $meta
309 );
310 }
311
312 private static function skipWarnings($skipped)
313 {
314 $out = [];
315 foreach ($skipped as $kind => $reason) {
316 $out[] = $kind . ': ' . $reason;
317 }
318 return $out;
319 }
320
321 /**
322 * Each kind is fetched behind a class_exists guard so a model that isn't
323 * present on a given install returns [] rather than fataling.
324 */
325 private static function fetchReferenceKind($kind)
326 {
327 try {
328 if ($kind === 'coupons' && class_exists('\FluentCart\App\Models\Coupon')) {
329 $coupons = \FluentCart\App\Models\Coupon::query()
330 ->select(['id', 'code', 'title', 'type', 'amount', 'status'])
331 ->orderBy('id', 'DESC')
332 ->limit(200)
333 ->get();
334 $out = [];
335 foreach ($coupons as $c) {
336 // Match list-coupons: numeric amount; fixed coupons stored in
337 // cents are reported in store currency, percentage as-is.
338 $amount = ($c->type === 'fixed')
339 ? 0 + \FluentCart\App\Helpers\Helper::toDecimalWithoutComma((int) $c->amount)
340 : (is_numeric($c->amount) ? 0 + $c->amount : $c->amount);
341 $out[] = [
342 'id' => (int) $c->id,
343 'code' => $c->code,
344 'title' => $c->title,
345 'type' => $c->type,
346 'amount' => $amount,
347 'status' => $c->status,
348 ];
349 }
350 return $out;
351 }
352
353 if ($kind === 'labels' && class_exists('\FluentCart\App\Models\Label')) {
354 // fct_label stores a single (maybe-serialized) `value` column —
355 // it may hold a plain title string or an array {title,color,…}.
356 // Labels are user-created and can grow large; cap like coupons
357 // so kinds[]=labels can't trigger an unbounded read/response.
358 $labels = \FluentCart\App\Models\Label::query()->orderBy('id', 'ASC')->limit(200)->get();
359 $out = [];
360 foreach ($labels as $label) {
361 $val = $label->value;
362 $entry = ['id' => (int) $label->id];
363 if (is_array($val)) {
364 $entry['title'] = isset($val['title']) ? $val['title'] : (isset($val['value']) ? $val['value'] : null);
365 if (isset($val['color'])) {
366 $entry['color'] = $val['color'];
367 }
368 } else {
369 $entry['title'] = $val;
370 }
371 $out[] = $entry;
372 }
373 return $out;
374 }
375
376 if ($kind === 'tax_classes' && class_exists('\FluentCart\App\Models\TaxClass')) {
377 // fct_tax_classes labels its name column `title`, not `name`.
378 return \FluentCart\App\Models\TaxClass::query()
379 ->select(['id', 'title'])
380 ->get()
381 ->toArray();
382 }
383
384 if ($kind === 'shipping_zones' && class_exists('\FluentCart\App\Models\ShippingZone')) {
385 // fct_shipping_zones labels its name column `name`, not `title`.
386 return \FluentCart\App\Models\ShippingZone::query()
387 ->select(['id', 'name', 'region'])
388 ->get()
389 ->toArray();
390 }
391
392 if ($kind === 'gateways') {
393 return self::enabledGateways();
394 }
395
396 if ($kind === 'product_categories') {
397 return self::productCategories();
398 }
399 } catch (\Throwable $e) {
400 return [];
401 }
402
403 return [];
404 }
405
406 /**
407 * Active payment gateways. Each gateway stores its own settings (there is no
408 * single payment_settings option), so we read the registered gateway
409 * instances from the GatewayManager and keep the ones with is_active=yes.
410 */
411 private static function enabledGateways()
412 {
413 $managerClass = '\FluentCart\App\Modules\PaymentMethods\Core\GatewayManager';
414 if (!class_exists($managerClass) || !method_exists($managerClass, 'getInstance')) {
415 return [];
416 }
417
418 try {
419 $gateways = $managerClass::getInstance()->all();
420 } catch (\Throwable $e) {
421 return [];
422 }
423
424 $out = [];
425 foreach ((array) $gateways as $gateway) {
426 if (!is_object($gateway) || !method_exists($gateway, 'getMeta')) {
427 continue;
428 }
429
430 $settings = (isset($gateway->settings) && is_object($gateway->settings) && method_exists($gateway->settings, 'get'))
431 ? (array) $gateway->settings->get()
432 : [];
433
434 $isActive = isset($settings['is_active'])
435 ? ($settings['is_active'] === 'yes')
436 : !empty($gateway->getMeta('status'));
437 if (!$isActive) {
438 continue;
439 }
440
441 $meta = (array) $gateway->getMeta();
442 $route = isset($meta['route']) ? $meta['route'] : null;
443 $out[] = [
444 'key' => $route,
445 'title' => isset($meta['title']) ? $meta['title'] : $route,
446 'mode' => isset($settings['payment_mode'])
447 ? $settings['payment_mode']
448 : (isset($settings['checkout_mode']) ? $settings['checkout_mode'] : null),
449 ];
450 }
451 return $out;
452 }
453
454 /** Product categories from the WP taxonomy (best-effort across naming). */
455 private static function productCategories()
456 {
457 foreach (['fluent-cart-category', 'product_cat', 'fluent_cart_category'] as $taxonomy) {
458 if (!taxonomy_exists($taxonomy)) {
459 continue;
460 }
461 $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 200]);
462 if (is_wp_error($terms)) {
463 continue;
464 }
465 $out = [];
466 foreach ($terms as $term) {
467 $out[] = ['id' => (int) $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'count' => (int) $term->count];
468 }
469 return $out;
470 }
471 return [];
472 }
473
474 /**
475 * Clear the cached context for all users. Hooked from MCPInit onto the
476 * events that change anything the context payload reports.
477 */
478 public static function invalidateCache()
479 {
480 global $wpdb;
481
482 $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
483 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
484
485 $like = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
486 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
487 }
488 }
489