PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.2
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.2, at includes/core/AI/ToolGateway.php

568 lines 18.6 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 return [
174 'content' => sprintf( 'Unknown tool "%s".', $tool_name ),
175 'is_error' => true,
176 ];
177 }
178 $result = $ability->execute( $arguments );
179 } else {
180 $public = AbilitiesRegistrar::getPublicDefinitions();
181 if ( ! isset( $public[ $tool_name ] ) ) {
182 return [
183 'content' => sprintf( 'Unknown tool "%s".', $tool_name ),
184 'is_error' => true,
185 ];
186 }
187
188 $all_defs = AbilitiesRegistrar::getDefinitions();
189 if ( ! isset( $all_defs[ $tool_name ]['execute_callback'] ) ) {
190 return [
191 'content' => sprintf( 'Execution callback missing for tool "%s".', $tool_name ),
192 'is_error' => true,
193 ];
194 }
195
196 try {
197 $result = call_user_func( $all_defs[ $tool_name ]['execute_callback'], $arguments );
198 } catch ( \Throwable $e ) {
199 $result = new \WP_Error( 'tool_exception', $e->getMessage() );
200 }
201 }
202
203 if ( is_wp_error( $result ) ) {
204 return [
205 'content' => wp_json_encode(
206 [
207 'error' => $result->get_error_code(),
208 'message' => $result->get_error_message(),
209 'data' => $result->get_error_data(),
210 ]
211 ),
212 'is_error' => true,
213 ];
214 }
215
216 $encoded = wp_json_encode( $result );
217 if ( false === $encoded ) {
218 $encoded = '"<unencodable result>"';
219 }
220
221 if ( strlen( $encoded ) > self::RESULT_CHAR_LIMIT ) {
222 $encoded = substr( $encoded, 0, self::RESULT_CHAR_LIMIT )
223 . '"...[truncated — narrow the query with filters or smaller per_page]"';
224 }
225
226 do_action( 'wpfnl_ai_tool_executed', $tool_name, $arguments, $result );
227
228 return [
229 'content' => $encoded,
230 'is_error' => false,
231 ];
232 }
233
234 /**
235 * Progress decoration for a tool, if it marks a notable build stage.
236 *
237 * @param string $tool_name Ability name.
238 * @return array{label: string, pct: int}|null
239 */
240 public static function progressFor( $tool_name ) {
241 return isset( self::PROGRESS_MAP[ $tool_name ] ) ? self::PROGRESS_MAP[ $tool_name ] : null;
242 }
243
244 /**
245 * Build an inline preview descriptor for a successful tool call.
246 *
247 * Only funnel-shaped previews are supported today (the canvas/flow
248 * preview) — step/variant preview cards can extend this once their
249 * renderers exist. Returns null for anything that shouldn't render a
250 * card, so the thread falls back to no preview rather than raw JSON.
251 *
252 * @param string $tool_name Ability name.
253 * @param array $result Result from executeTool().
254 * @return array|null
255 */
256 public static function previewFor( $tool_name, $result ) {
257 if ( empty( $result ) || ! empty( $result['is_error'] ) || ! in_array( $tool_name, self::PREVIEW_TOOLS, true ) ) {
258 return null;
259 }
260
261 $decoded = json_decode( (string) ( isset( $result['content'] ) ? $result['content'] : '' ), true );
262 if ( ! is_array( $decoded ) ) {
263 return null;
264 }
265
266 $funnel_id = 0;
267 if ( ! empty( $decoded['funnel_id'] ) ) {
268 $funnel_id = (int) $decoded['funnel_id'];
269 } elseif ( 'wpfunnels/create-funnel' === $tool_name && ! empty( $decoded['id'] ) ) {
270 $funnel_id = (int) $decoded['id'];
271 }
272
273 if ( ! $funnel_id ) {
274 return null;
275 }
276
277 $post = get_post( $funnel_id );
278 if ( ! $post || ! defined( 'WPFNL_FUNNELS_POST_TYPE' ) || WPFNL_FUNNELS_POST_TYPE !== $post->post_type ) {
279 return null;
280 }
281
282 return MCPHelper::formatFunnel( $post, true );
283 }
284
285 /**
286 * Short human-readable summary line for confirmation logging.
287 *
288 * @param string $tool_name Fully-qualified ability name.
289 * @param array $arguments Tool arguments.
290 * @return string
291 */
292 public static function describeCall( $tool_name, $arguments = [] ) {
293 $details = self::describeCallDetails( $tool_name, $arguments );
294 $line = $details['title'];
295 if ( '' !== $details['subject'] ) {
296 $line .= ': ' . $details['subject'];
297 }
298 return '' !== $details['note'] ? $line . ' — ' . $details['note'] : $line;
299 }
300
301 /**
302 * Structured, plain-English description of a queued tool call.
303 *
304 * @param string $tool_name Fully-qualified ability name.
305 * @param array $arguments Tool arguments.
306 * @return array{tool: string, action: string, kind: string, title: string, subject: string, note: string, risk: string, fields: array, raw: string}
307 */
308 public static function describeCallDetails( $tool_name, $arguments = [] ) {
309 $arguments = is_array( $arguments ) ? $arguments : [];
310 $short = str_replace( 'wpfunnels/', '', $tool_name );
311 $raw = (string) wp_json_encode( $arguments );
312 if ( strlen( $raw ) > 400 ) {
313 $raw = substr( $raw, 0, 400 ) . '…';
314 }
315
316 $defaults = [
317 'tool' => $short,
318 'action' => isset( $arguments['action'] ) ? (string) $arguments['action'] : '',
319 'kind' => 'generic',
320 'title' => self::humanizeToolName( $short ),
321 'subject' => self::guessSubject( $arguments ),
322 'note' => '',
323 'risk' => 'high',
324 'fields' => self::humanizeArguments( $arguments ),
325 ];
326
327 $known = self::describeKnownCall( $tool_name, $arguments );
328
329 return array_merge( $defaults, $known, [ 'raw' => $raw ] );
330 }
331
332 /**
333 * Per-tool overrides for describeCallDetails().
334 *
335 * @param string $tool_name Fully-qualified ability name.
336 * @param array $arguments Tool arguments.
337 * @return array
338 */
339 private static function describeKnownCall( $tool_name, $arguments ) {
340 switch ( $tool_name ) {
341 case 'wpfunnels/create-funnel':
342 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
343 $type = isset( $arguments['type'] ) ? (string) $arguments['type'] : 'wc';
344 return [
345 'kind' => 'funnel',
346 'action' => 'create',
347 'title' => __( 'Create a new funnel', 'wpfnl' ),
348 'subject' => $name,
349 'note' => __( 'Initializes the funnel. Steps and products will be added next.', 'wpfnl' ),
350 'risk' => 'low',
351 'fields' => self::fields(
352 [
353 __( 'Funnel Name', 'wpfnl' ) => $name,
354 __( 'Funnel Type', 'wpfnl' ) => $type,
355 ]
356 ),
357 ];
358
359 case 'wpfunnels/update-funnel':
360 $id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
361 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
362 $status = isset( $arguments['status'] ) ? (string) $arguments['status'] : '';
363 $type = isset( $arguments['type'] ) ? (string) $arguments['type'] : '';
364
365 $subject = '' !== $name ? $name : self::refLabel( $id );
366 $is_pub = 'publish' === $status;
367
368 return [
369 'kind' => 'funnel',
370 'action' => $status ? $status : 'update',
371 'title' => $is_pub ? __( 'Publish funnel live', 'wpfnl' ) : __( 'Update funnel settings', 'wpfnl' ),
372 'subject' => $subject,
373 'note' => $is_pub ? __( 'This makes the funnel live and accessible to site visitors.', 'wpfnl' ) : '',
374 'risk' => $is_pub ? 'high' : 'medium',
375 'fields' => self::fields(
376 [
377 __( 'Funnel', 'wpfnl' ) => $subject,
378 __( 'Status', 'wpfnl' ) => $status,
379 __( 'Type', 'wpfnl' ) => $type,
380 ]
381 ),
382 ];
383
384 case 'wpfunnels/create-step':
385 $funnel_id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
386 $step_type = isset( $arguments['step_type'] ) ? (string) $arguments['step_type'] : '';
387 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : ucfirst( $step_type );
388
389 return [
390 'kind' => 'step',
391 'action' => 'create',
392 'title' => sprintf( __( 'Add %s step', 'wpfnl' ), $step_type ),
393 'subject' => $name,
394 'note' => __( 'Adds a new step to the funnel flow.', 'wpfnl' ),
395 'risk' => 'low',
396 'fields' => self::fields(
397 [
398 __( 'Step Name', 'wpfnl' ) => $name,
399 __( 'Step Type', 'wpfnl' ) => $step_type,
400 __( 'Funnel ID', 'wpfnl' ) => (string) $funnel_id,
401 ]
402 ),
403 ];
404
405 case 'wpfunnels/update-step':
406 $id = isset( $arguments['step_id'] ) ? (int) $arguments['step_id'] : 0;
407 $name = isset( $arguments['name'] ) ? (string) $arguments['name'] : '';
408 $status = isset( $arguments['status'] ) ? (string) $arguments['status'] : '';
409
410 return [
411 'kind' => 'step',
412 'action' => 'update',
413 'title' => __( 'Update step settings', 'wpfnl' ),
414 'subject' => '' !== $name ? $name : self::refLabel( $id ),
415 'note' => __( 'Renaming a step changes its URL slug.', 'wpfnl' ),
416 'risk' => 'medium',
417 'fields' => self::fields(
418 [
419 __( 'Step', 'wpfnl' ) => self::refLabel( $id ),
420 __( 'Name', 'wpfnl' ) => $name,
421 __( 'Status', 'wpfnl' ) => $status,
422 ]
423 ),
424 ];
425
426 case 'wpfunnels/reorder-steps':
427 $funnel_id = isset( $arguments['funnel_id'] ) ? (int) $arguments['funnel_id'] : 0;
428 $step_ids = isset( $arguments['step_ids'] ) && is_array( $arguments['step_ids'] ) ? $arguments['step_ids'] : [];
429
430 return [
431 'kind' => 'step',
432 'action' => 'reorder',
433 'title' => __( 'Reorder funnel steps', 'wpfnl' ),
434 'subject' => sprintf( __( 'Funnel #%d', 'wpfnl' ), $funnel_id ),
435 'note' => __( 'Replaces the funnel step sequence. Omitted steps are removed.', 'wpfnl' ),
436 'risk' => 'high',
437 'fields' => self::fields(
438 [
439 __( 'Funnel ID', 'wpfnl' ) => (string) $funnel_id,
440 __( 'New Order', 'wpfnl' ) => implode( ' → ', $step_ids ),
441 ]
442 ),
443 ];
444
445 case 'wpfunnels/assign-products-to-step':
446 $step_id = isset( $arguments['step_id'] ) ? (int) $arguments['step_id'] : 0;
447 $products = isset( $arguments['products'] ) && is_array( $arguments['products'] ) ? $arguments['products'] : [];
448 $discount = isset( $arguments['discount_percentage'] ) ? (float) $arguments['discount_percentage'] : 0;
449
450 $p_ids = array_filter( array_column( $products, 'product_id' ) );
451
452 return [
453 'kind' => 'product',
454 'action' => 'assign',
455 'title' => __( 'Assign products to step', 'wpfnl' ),
456 'subject' => sprintf( __( 'Step #%d', 'wpfnl' ), $step_id ),
457 'note' => __( 'Replaces existing products attached to this step.', 'wpfnl' ),
458 'risk' => 'high',
459 'fields' => self::fields(
460 [
461 __( 'Step ID', 'wpfnl' ) => (string) $step_id,
462 __( 'Product IDs', 'wpfnl' ) => implode( ', ', $p_ids ),
463 __( 'Discount', 'wpfnl' ) => $discount > 0 ? $discount . '%' : __( 'None', 'wpfnl' ),
464 ]
465 ),
466 ];
467 }
468
469 return [];
470 }
471
472 /**
473 * Build label/value field rows, dropping empties.
474 *
475 * @param array $pairs label => value.
476 * @return array
477 */
478 private static function fields( array $pairs ) {
479 $fields = [];
480 foreach ( $pairs as $label => $value ) {
481 if ( null === $value || '' === $value ) {
482 continue;
483 }
484 $fields[] = [
485 'label' => (string) $label,
486 'value' => (string) $value,
487 ];
488 }
489 return $fields;
490 }
491
492 /**
493 * Generic argument humanizer.
494 *
495 * @param array $arguments Tool arguments.
496 * @return array
497 */
498 private static function humanizeArguments( array $arguments ) {
499 $pairs = [];
500 foreach ( $arguments as $key => $value ) {
501 if ( 'confirm' === $key ) {
502 continue;
503 }
504 if ( is_bool( $value ) ) {
505 $value = $value ? __( 'Yes', 'wpfnl' ) : __( 'No', 'wpfnl' );
506 } elseif ( is_array( $value ) || is_object( $value ) ) {
507 $value = (string) wp_json_encode( $value );
508 }
509 if ( ! is_scalar( $value ) ) {
510 continue;
511 }
512 $value = (string) $value;
513 if ( strlen( $value ) > 120 ) {
514 $value = substr( $value, 0, 120 ) . '…';
515 }
516 $pairs[ self::humanizeKey( (string) $key ) ] = $value;
517 }
518 return self::fields( $pairs );
519 }
520
521 /**
522 * Humanize an argument key (e.g. `funnel_id` -> "Funnel ID").
523 *
524 * @param string $key Key slug.
525 * @return string
526 */
527 private static function humanizeKey( $key ) {
528 $key = trim( str_replace( '_', ' ', $key ) );
529 $key = (string) preg_replace( '/\bid\b/i', 'ID', $key );
530 return ucfirst( $key );
531 }
532
533 /**
534 * Humanize a tool name (e.g. `create-funnel` -> "Create funnel").
535 *
536 * @param string $short Short tool name.
537 * @return string
538 */
539 private static function humanizeToolName( $short ) {
540 return ucfirst( trim( str_replace( '-', ' ', $short ) ) );
541 }
542
543 /**
544 * Guess the subject line from common name-like arguments.
545 *
546 * @param array $arguments Tool input.
547 * @return string
548 */
549 private static function guessSubject( array $arguments ) {
550 foreach ( [ 'title', 'name', 'funnel_name', 'step_name' ] as $key ) {
551 if ( ! empty( $arguments[ $key ] ) && is_scalar( $arguments[ $key ] ) ) {
552 return (string) $arguments[ $key ];
553 }
554 }
555 return '';
556 }
557
558 /**
559 * Reference label fallback.
560 *
561 * @param int $id Record ID.
562 * @return string
563 */
564 private static function refLabel( $id ) {
565 return $id > 0 ? sprintf( '#%d', $id ) : __( 'the selected item', 'wpfnl' );
566 }
567 }
568