PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.3
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.3
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / AI / ToolGateway.php

ToolGateway.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.3, at includes/core/AI/ToolGateway.php

580 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ToolGateway — bridges the agent loop to the WordPress Abilities registry.
4 *
5 * The AI copilot and the external MCP server share ONE tool surface: every
6 * 'wpfunnels' category ability. WP_Ability::execute() enforces input validation
7 * and the per-tool permission_callback as the logged-in user, so the loop never
8 * bypasses the permission model.
9 *
10 * @package WPFunnels\AI
11 * @since 3.13.0
12 */
13
14 namespace WPFunnels\AI;
15
16 defined( 'ABSPATH' ) || exit;
17
18 use WPFunnels\MCP\AbilitiesRegistrar;
19 use WPFunnels\MCP\Helpers\MCPHelper;
20
21 /**
22 * Class ToolGateway
23 */
24 class ToolGateway {
25
26 /**
27 * Cap on a single tool result fed back to the model — protects the
28 * context window from a massive response payload.
29 */
30 private const RESULT_CHAR_LIMIT = 12000;
31
32 /**
33 * Tools the loop never auto-executes; the user must approve in the UI.
34 */
35 private const ALWAYS_CONFIRM = [
36 'wpfunnels/update-funnel',
37 'wpfunnels/reorder-steps',
38 'wpfunnels/assign-products-to-step',
39 'wpfunnels/update-global-settings',
40 'wpfunnels/configure-store-checkout',
41 'wpfunnels/set-store-checkout-conditions',
42 'wpfunnels/declare-winner',
43 'wpfunnels/set-automation-status',
44 'wpfunnels/upsert-automation',
45 ];
46
47 /**
48 * Tool → { label, pct } decoration for the copilot's progress bar.
49 *
50 * Purely cosmetic: the client falls back to a local guess when a tool
51 * isn't listed here, so this only needs the stages worth calling out by
52 * name (plan §6.3).
53 */
54 private const PROGRESS_MAP = [
55 'wpfunnels/get-funnel-context' => [ 'label' => 'Reviewing your store…', 'pct' => 10 ],
56 'wpfunnels/check-setup-status' => [ 'label' => 'Checking setup…', 'pct' => 10 ],
57 'wpfunnels/search-products' => [ 'label' => 'Finding products…', 'pct' => 20 ],
58 'wpfunnels/get-store-analysis' => [ 'label' => 'Analyzing your store…', 'pct' => 20 ],
59 'wpfunnels/list-funnel-templates' => [ 'label' => 'Checking templates…', 'pct' => 25 ],
60 'wpfunnels/create-funnel' => [ 'label' => 'Creating funnel…', 'pct' => 30 ],
61 'wpfunnels/import-funnel-template' => [ 'label' => 'Importing template design…', 'pct' => 40 ],
62 'wpfunnels/create-step' => [ 'label' => 'Adding step…', 'pct' => 45 ],
63 'wpfunnels/assign-products-to-step' => [ 'label' => 'Assigning products…', 'pct' => 55 ],
64 'wpfunnels/upsert-order-bump' => [ 'label' => 'Adding order bump…', 'pct' => 65 ],
65 'wpfunnels/upsert-upsell-offer' => [ 'label' => 'Configuring upsell…', 'pct' => 70 ],
66 'wpfunnels/upsert-downsell-offer' => [ 'label' => 'Configuring downsell…', 'pct' => 72 ],
67 'wpfunnels/compose-step-page' => [ 'label' => 'Building the page…', 'pct' => 80 ],
68 'wpfunnels/rewrite-step-copy' => [ 'label' => 'Updating copy…', 'pct' => 82 ],
69 'wpfunnels/get-funnel-analytics' => [ 'label' => 'Pulling analytics…', 'pct' => 85 ],
70 ];
71
72 /**
73 * Tools whose successful result renders an inline preview card in the
74 * thread instead of raw JSON (plan §6.3).
75 */
76 private const PREVIEW_TOOLS = [
77 'wpfunnels/create-funnel',
78 'wpfunnels/import-funnel-template',
79 'wpfunnels/update-funnel',
80 'wpfunnels/create-step',
81 'wpfunnels/reorder-steps',
82 'wpfunnels/upsert-order-bump',
83 'wpfunnels/compose-step-page',
84 ];
85
86 /**
87 * Normalized tool definitions the current user may call.
88 *
89 * @return array [ {name, label, description, input_schema, destructive, annotations} ]
90 */
91 public static function toolDefinitions() {
92 $definitions = [];
93
94 if ( function_exists( 'wp_get_abilities' ) ) {
95 foreach ( wp_get_abilities() as $ability ) {
96 $name = $ability->get_name();
97 if ( ! str_starts_with( $name, 'wpfunnels/' ) ) {
98 continue;
99 }
100 if ( true !== $ability->check_permissions( [] ) ) {
101 continue;
102 }
103
104 $schema = $ability->get_input_schema();
105 $annotations = $ability->get_meta_item( 'annotations', [] );
106 $definitions[] = [
107 'name' => $name,
108 'label' => $ability->get_label(),
109 'description' => $ability->get_description(),
110 'input_schema' => ! empty( $schema ) ? AbilitiesRegistrar::normalizeSchema( $schema ) : [ 'type' => 'object', 'properties' => new \stdClass() ],
111 'destructive' => is_array( $annotations ) && ! empty( $annotations['destructive'] ),
112 'annotations' => is_array( $annotations ) ? array_keys( array_filter( $annotations ) ) : [],
113 ];
114 }
115 } else {
116 // Fallback to definitions array when core Abilities API functions are missing or stubbed.
117 foreach ( AbilitiesRegistrar::getPublicDefinitions() as $name => $def ) {
118 $definitions[] = [
119 'name' => $name,
120 'label' => isset( $def['label'] ) ? $def['label'] : $name,
121 'description' => isset( $def['description'] ) ? $def['description'] : '',
122 'input_schema' => isset( $def['input_schema'] ) ? $def['input_schema'] : [ 'type' => 'object', 'properties' => new \stdClass() ],
123 'destructive' => ! empty( $def['destructive'] ),
124 'annotations' => isset( $def['annotations'] ) ? $def['annotations'] : [],
125 ];
126 }
127 }
128
129 return $definitions;
130 }
131
132 /**
133 * Whether a tool requires explicit user confirmation before execution.
134 *
135 * @param string $tool_name Ability name.
136 * @return bool
137 */
138 public static function requiresConfirmation( $tool_name ) {
139 if ( in_array( $tool_name, self::ALWAYS_CONFIRM, true ) ) {
140 return true;
141 }
142
143 if ( function_exists( 'wp_get_ability' ) ) {
144 $ability = wp_get_ability( $tool_name );
145 if ( $ability ) {
146 $annotations = $ability->get_meta_item( 'annotations', [] );
147 return is_array( $annotations ) && ! empty( $annotations['destructive'] );
148 }
149 }
150
151 $public = AbilitiesRegistrar::getPublicDefinitions();
152 if ( isset( $public[ $tool_name ] ) ) {
153 return ! empty( $public[ $tool_name ]['destructive'] );
154 }
155
156 return true;
157 }
158
159 /**
160 * Execute a tool as the current user. Returns a JSON string content payload
161 * and an error flag.
162 *
163 * @param string $tool_name Ability name.
164 * @param array $arguments Tool input.
165 * @return array{content: string, is_error: bool}
166 */
167 public static function executeTool( $tool_name, $arguments = [] ) {
168 $arguments = is_array( $arguments ) ? $arguments : [];
169
170 if ( function_exists( 'wp_get_ability' ) ) {
171 $ability = wp_get_ability( $tool_name );
172 if ( ! $ability || ! str_starts_with( $tool_name, 'wpfunnels/' ) ) {
173 do_action( 'wpfnl_ai_tool_failed', $tool_name, 'unknown_tool' );
174 return [
175 'content' => sprintf( 'Unknown tool "%s".', $tool_name ),
176 'is_error' => true,
177 ];
178 }
179 $result = $ability->execute( $arguments );
180 } else {
181 $public = AbilitiesRegistrar::getPublicDefinitions();
182 if ( ! isset( $public[ $tool_name ] ) ) {
183 return [
184 'content' => sprintf( 'Unknown tool "%s".', $tool_name ),
185 'is_error' => true,
186 ];
187 }
188
189 $all_defs = AbilitiesRegistrar::getDefinitions();
190 if ( ! isset( $all_defs[ $tool_name ]['execute_callback'] ) ) {
191 return [
192 'content' => sprintf( 'Execution callback missing for tool "%s".', $tool_name ),
193 'is_error' => true,
194 ];
195 }
196
197 try {
198 $result = call_user_func( $all_defs[ $tool_name ]['execute_callback'], $arguments );
199 } catch ( \Throwable $e ) {
200 $result = new \WP_Error( 'tool_exception', $e->getMessage() );
201 }
202 }
203
204 if ( is_wp_error( $result ) ) {
205 /**
206 * Fires when a copilot tool call fails (the counterpart of
207 * `wpfnl_ai_tool_executed`).
208 *
209 * @since 3.14.0
210 *
211 * @param string $tool_name Ability name.
212 * @param string $error_code WP_Error code.
213 */
214 do_action( 'wpfnl_ai_tool_failed', $tool_name, $result->get_error_code() );
215
216 return [
217 'content' => wp_json_encode(
218 [
219 'error' => $result->get_error_code(),
220 'message' => $result->get_error_message(),
221 'data' => $result->get_error_data(),
222 ]
223 ),
224 'is_error' => true,
225 ];
226 }
227
228 $encoded = wp_json_encode( $result );
229 if ( false === $encoded ) {
230 $encoded = '"<unencodable result>"';
231 }
232
233 if ( strlen( $encoded ) > self::RESULT_CHAR_LIMIT ) {
234 $encoded = substr( $encoded, 0, self::RESULT_CHAR_LIMIT )
235 . '"...[truncated — narrow the query with filters or smaller per_page]"';
236 }
237
238 do_action( 'wpfnl_ai_tool_executed', $tool_name, $arguments, $result );
239
240 return [
241 'content' => $encoded,
242 'is_error' => false,
243 ];
244 }
245
246 /**
247 * Progress decoration for a tool, if it marks a notable build stage.
248 *
249 * @param string $tool_name Ability name.
250 * @return array{label: string, pct: int}|null
251 */
252 public static function progressFor( $tool_name ) {
253 return isset( self::PROGRESS_MAP[ $tool_name ] ) ? self::PROGRESS_MAP[ $tool_name ] : null;
254 }
255
256 /**
257 * Build an inline preview descriptor for a successful tool call.
258 *
259 * Only funnel-shaped previews are supported today (the canvas/flow
260 * preview) — step/variant preview cards can extend this once their
261 * renderers exist. Returns null for anything that shouldn't render a
262 * card, so the thread falls back to no preview rather than raw JSON.
263 *
264 * @param string $tool_name Ability name.
265 * @param array $result Result from executeTool().
266 * @return array|null
267 */
268 public static function previewFor( $tool_name, $result ) {
269 if ( empty( $result ) || ! empty( $result['is_error'] ) || ! in_array( $tool_name, self::PREVIEW_TOOLS, true ) ) {
270 return null;
271 }
272
273 $decoded = json_decode( (string) ( isset( $result['content'] ) ? $result['content'] : '' ), true );
274 if ( ! is_array( $decoded ) ) {
275 return null;
276 }
277
278 $funnel_id = 0;
279 if ( ! empty( $decoded['funnel_id'] ) ) {
280 $funnel_id = (int) $decoded['funnel_id'];
281 } elseif ( 'wpfunnels/create-funnel' === $tool_name && ! empty( $decoded['id'] ) ) {
282 $funnel_id = (int) $decoded['id'];
283 }
284
285 if ( ! $funnel_id ) {
286 return null;
287 }
288
289 $post = get_post( $funnel_id );
290 if ( ! $post || ! defined( 'WPFNL_FUNNELS_POST_TYPE' ) || WPFNL_FUNNELS_POST_TYPE !== $post->post_type ) {
291 return null;
292 }
293
294 return MCPHelper::formatFunnel( $post, true );
295 }
296
297 /**
298 * Short human-readable summary line for confirmation logging.
299 *
300 * @param string $tool_name Fully-qualified ability name.
301 * @param array $arguments Tool arguments.
302 * @return string
303 */
304 public static function describeCall( $tool_name, $arguments = [] ) {
305 $details = self::describeCallDetails( $tool_name, $arguments );
306 $line = $details['title'];
307 if ( '' !== $details['subject'] ) {
308 $line .= ': ' . $details['subject'];
309 }
310 return '' !== $details['note'] ? $line . ' — ' . $details['note'] : $line;
311 }
312
313 /**
314 * Structured, plain-English description of a queued tool call.
315 *
316 * @param string $tool_name Fully-qualified ability name.
317 * @param array $arguments Tool arguments.
318 * @return array{tool: string, action: string, kind: string, title: string, subject: string, note: string, risk: string, fields: array, raw: string}
319 */
320 public static function describeCallDetails( $tool_name, $arguments = [] ) {
321 $arguments = is_array( $arguments ) ? $arguments : [];
322 $short = str_replace( 'wpfunnels/', '', $tool_name );
323 $raw = (string) wp_json_encode( $arguments );
324 if ( strlen( $raw ) > 400 ) {
325 $raw = substr( $raw, 0, 400 ) . '…';
326 }
327
328 $defaults = [
329 'tool' => $short,
330 'action' => isset( $arguments['action'] ) ? (string) $arguments['action'] : '',
331 'kind' => 'generic',
332 'title' => self::humanizeToolName( $short ),
333 'subject' => self::guessSubject( $arguments ),
334 'note' => '',
335 'risk' => 'high',
336 'fields' => self::humanizeArguments( $arguments ),
337 ];
338
339 $known = self::describeKnownCall( $tool_name, $arguments );
340
341 return array_merge( $defaults, $known, [ 'raw' => $raw ] );
342 }
343
344 /**
345 * Per-tool overrides for describeCallDetails().
346 *
347 * @param string $tool_name Fully-qualified ability name.
348 * @param array $arguments Tool arguments.
349 * @return array
350 */
351 private static function describeKnownCall( $tool_name, $arguments ) {
352 switch ( $tool_name ) {
353 case 'wpfunnels/create-funnel':
354 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
355 $type = isset( $arguments['type'] ) ? (string) $arguments['type'] : 'wc';
356 return [
357 'kind' => 'funnel',
358 'action' => 'create',
359 'title' => __( 'Create a new funnel', 'wpfnl' ),
360 'subject' => $name,
361 'note' => __( 'Initializes the funnel. Steps and products will be added next.', 'wpfnl' ),
362 'risk' => 'low',
363 'fields' => self::fields(
364 [
365 __( 'Funnel Name', 'wpfnl' ) => $name,
366 __( 'Funnel Type', 'wpfnl' ) => $type,
367 ]
368 ),
369 ];
370
371 case 'wpfunnels/update-funnel':
372 $id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
373 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
374 $status = isset( $arguments['status'] ) ? (string) $arguments['status'] : '';
375 $type = isset( $arguments['type'] ) ? (string) $arguments['type'] : '';
376
377 $subject = '' !== $name ? $name : self::refLabel( $id );
378 $is_pub = 'publish' === $status;
379
380 return [
381 'kind' => 'funnel',
382 'action' => $status ? $status : 'update',
383 'title' => $is_pub ? __( 'Publish funnel live', 'wpfnl' ) : __( 'Update funnel settings', 'wpfnl' ),
384 'subject' => $subject,
385 'note' => $is_pub ? __( 'This makes the funnel live and accessible to site visitors.', 'wpfnl' ) : '',
386 'risk' => $is_pub ? 'high' : 'medium',
387 'fields' => self::fields(
388 [
389 __( 'Funnel', 'wpfnl' ) => $subject,
390 __( 'Status', 'wpfnl' ) => $status,
391 __( 'Type', 'wpfnl' ) => $type,
392 ]
393 ),
394 ];
395
396 case 'wpfunnels/create-step':
397 $funnel_id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
398 $step_type = isset( $arguments['step_type'] ) ? (string) $arguments['step_type'] : '';
399 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : ucfirst( $step_type );
400
401 return [
402 'kind' => 'step',
403 'action' => 'create',
404 'title' => sprintf( __( 'Add %s step', 'wpfnl' ), $step_type ),
405 'subject' => $name,
406 'note' => __( 'Adds a new step to the funnel flow.', 'wpfnl' ),
407 'risk' => 'low',
408 'fields' => self::fields(
409 [
410 __( 'Step Name', 'wpfnl' ) => $name,
411 __( 'Step Type', 'wpfnl' ) => $step_type,
412 __( 'Funnel ID', 'wpfnl' ) => (string) $funnel_id,
413 ]
414 ),
415 ];
416
417 case 'wpfunnels/update-step':
418 $id = isset( $arguments['step_id'] ) ? (int) $arguments['step_id'] : 0;
419 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
420 $status = isset( $arguments['status'] ) ? (string) $arguments['status'] : '';
421
422 return [
423 'kind' => 'step',
424 'action' => 'update',
425 'title' => __( 'Update step settings', 'wpfnl' ),
426 'subject' => '' !== $name ? $name : self::refLabel( $id ),
427 'note' => __( 'Renaming a step changes its URL slug.', 'wpfnl' ),
428 'risk' => 'medium',
429 'fields' => self::fields(
430 [
431 __( 'Step', 'wpfnl' ) => self::refLabel( $id ),
432 __( 'Name', 'wpfnl' ) => $name,
433 __( 'Status', 'wpfnl' ) => $status,
434 ]
435 ),
436 ];
437
438 case 'wpfunnels/reorder-steps':
439 $funnel_id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
440 $step_ids = isset( $arguments['step_ids'] ) && is_array( $arguments['step_ids'] ) ? $arguments['step_ids'] : [];
441
442 return [
443 'kind' => 'step',
444 'action' => 'reorder',
445 'title' => __( 'Reorder funnel steps', 'wpfnl' ),
446 'subject' => sprintf( __( 'Funnel #%d', 'wpfnl' ), $funnel_id ),
447 'note' => __( 'Replaces the funnel step sequence. Omitted steps are removed.', 'wpfnl' ),
448 'risk' => 'high',
449 'fields' => self::fields(
450 [
451 __( 'Funnel ID', 'wpfnl' ) => (string) $funnel_id,
452 __( 'New Order', 'wpfnl' ) => implode( ' → ', $step_ids ),
453 ]
454 ),
455 ];
456
457 case 'wpfunnels/assign-products-to-step':
458 $step_id = isset( $arguments['step_id'] ) ? (int) $arguments['step_id'] : 0;
459 $products = isset( $arguments['products'] ) && is_array( $arguments['products'] ) ? $arguments['products'] : [];
460 $discount = isset( $arguments['discount_percentage'] ) ? (float) $arguments['discount_percentage'] : 0;
461
462 $p_ids = array_filter( array_column( $products, 'product_id' ) );
463
464 return [
465 'kind' => 'product',
466 'action' => 'assign',
467 'title' => __( 'Assign products to step', 'wpfnl' ),
468 'subject' => sprintf( __( 'Step #%d', 'wpfnl' ), $step_id ),
469 'note' => __( 'Replaces existing products attached to this step.', 'wpfnl' ),
470 'risk' => 'high',
471 'fields' => self::fields(
472 [
473 __( 'Step ID', 'wpfnl' ) => (string) $step_id,
474 __( 'Product IDs', 'wpfnl' ) => implode( ', ', $p_ids ),
475 __( 'Discount', 'wpfnl' ) => $discount > 0 ? $discount . '%' : __( 'None', 'wpfnl' ),
476 ]
477 ),
478 ];
479 }
480
481 return [];
482 }
483
484 /**
485 * Build label/value field rows, dropping empties.
486 *
487 * @param array $pairs label => value.
488 * @return array
489 */
490 private static function fields( array $pairs ) {
491 $fields = [];
492 foreach ( $pairs as $label => $value ) {
493 if ( null === $value || '' === $value ) {
494 continue;
495 }
496 $fields[] = [
497 'label' => (string) $label,
498 'value' => (string) $value,
499 ];
500 }
501 return $fields;
502 }
503
504 /**
505 * Generic argument humanizer.
506 *
507 * @param array $arguments Tool arguments.
508 * @return array
509 */
510 private static function humanizeArguments( array $arguments ) {
511 $pairs = [];
512 foreach ( $arguments as $key => $value ) {
513 if ( 'confirm' === $key ) {
514 continue;
515 }
516 if ( is_bool( $value ) ) {
517 $value = $value ? __( 'Yes', 'wpfnl' ) : __( 'No', 'wpfnl' );
518 } elseif ( is_array( $value ) || is_object( $value ) ) {
519 $value = (string) wp_json_encode( $value );
520 }
521 if ( ! is_scalar( $value ) ) {
522 continue;
523 }
524 $value = (string) $value;
525 if ( strlen( $value ) > 120 ) {
526 $value = substr( $value, 0, 120 ) . '…';
527 }
528 $pairs[ self::humanizeKey( (string) $key ) ] = $value;
529 }
530 return self::fields( $pairs );
531 }
532
533 /**
534 * Humanize an argument key (e.g. `funnel_id` -> "Funnel ID").
535 *
536 * @param string $key Key slug.
537 * @return string
538 */
539 private static function humanizeKey( $key ) {
540 $key = trim( str_replace( '_', ' ', $key ) );
541 $key = (string) preg_replace( '/\bid\b/i', 'ID', $key );
542 return ucfirst( $key );
543 }
544
545 /**
546 * Humanize a tool name (e.g. `create-funnel` -> "Create funnel").
547 *
548 * @param string $short Short tool name.
549 * @return string
550 */
551 private static function humanizeToolName( $short ) {
552 return ucfirst( trim( str_replace( '-', ' ', $short ) ) );
553 }
554
555 /**
556 * Guess the subject line from common name-like arguments.
557 *
558 * @param array $arguments Tool input.
559 * @return string
560 */
561 private static function guessSubject( array $arguments ) {
562 foreach ( [ 'title', 'name', 'funnel_name', 'step_name' ] as $key ) {
563 if ( ! empty( $arguments[ $key ] ) && is_scalar( $arguments[ $key ] ) ) {
564 return (string) $arguments[ $key ];
565 }
566 }
567 return '';
568 }
569
570 /**
571 * Reference label fallback.
572 *
573 * @param int $id Record ID.
574 * @return string
575 */
576 private static function refLabel( $id ) {
577 return $id > 0 ? sprintf( '#%d', $id ) : __( 'the selected item', 'wpfnl' );
578 }
579 }
580