PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.16
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.16
4.2.16 4.2.15 4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 All 137 releases
optimole-wp / vendor / codeinwp / themeisle-sdk / src / Modules / Ai_connect.php

Ai_connect.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization 4.2.16, at vendor/codeinwp/themeisle-sdk/src/Modules/Ai_connect.php

1,033 lines 34.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The "Connect your AI agent" module for ThemeIsle SDK.
4 *
5 * Offers the Easy MCP connector to products that expose WordPress abilities:
6 * a plugin-row link, a dismissable notice and one modal that enables the
7 * connector and hands the user the deep links of their AI agent.
8 *
9 * Here's how to hook it in your product:
10 *
11 * add_filter( '<product_key>_ai_connect_metadata', 'add_ai_connect_meta' );
12 *
13 * function add_ai_connect_meta( $data ) {
14 * return [
15 * 'name' => <nice name>, // optional, defaults to the product's friendly name
16 * 'notice_cases' => [ 'optimize new uploads', 'purge cached images', 'offload originals' ], // 2-3 short use cases
17 * 'prompts' => [ 'Show me my delivery settings ...', ... ], // ready-to-copy prompts
18 * 'ability_prefix' => 'optimole', // optional, every registered "optimole/..." ability is switched on in Easy MCP on Enable, except the ones registered with meta 'ai_connect' => false
19 * 'abilities' => [ 'optimole/get-delivery-settings', ... ], // optional, explicit ability names to switch on as well
20 * 'internal_slug' => 'mlo', // optional, the slug the product passes to themeisle_internal_page when it is not its install slug
21 * ]
22 * }
23 *
24 * @package ThemeIsleSDK
25 * @subpackage Modules
26 * @copyright Copyright (c) 2026, Themeisle
27 * @license http://opensource.org/licenses/gpl-3.0.php GNU Public License
28 * @since 3.3.62
29 */
30
31 namespace ThemeisleSDK\Modules;
32
33 use ThemeisleSDK\Common\Abstract_Module;
34 use ThemeisleSDK\Loader;
35 use ThemeisleSDK\Product;
36
37 // Exit if accessed directly.
38 if ( ! defined( 'ABSPATH' ) ) {
39 exit;
40 }
41
42 /**
43 * AI Connect module for ThemeIsle SDK.
44 */
45 class Ai_Connect extends Abstract_Module {
46 const CONNECTOR_SLUG = 'easy-mcp-ai';
47 const CONNECTOR_FILE = 'easy-mcp-ai/easy-mcp-ai.php';
48 const CONNECTOR_ROUTE = 'easy-mcp-ai/v1/mcp';
49 const INTEGRATIONS_URL = 'https://easymcpai.com/integrations';
50 const ABILITIES_OPTION = 'easy_mcp_ai_enabled_abilities';
51
52 /**
53 * Easy MCP arms this one-shot transient on activation and its admin page
54 * redirects to the setup wizard once it is found (Setup_State::REDIRECT_TRANSIENT).
55 */
56 const CONNECTOR_REDIRECT_TRANSIENT = 'easy_mcp_ai_setup_redirect';
57
58 const AJAX_ENABLE = 'themeisle_sdk_ai_connect_enable';
59 const AJAX_DISMISS = 'themeisle_sdk_ai_connect_dismiss';
60 const NONCE = 'themeisle_sdk_ai_connect';
61 const DISMISSED_KEY = 'themeisle_sdk_ai_connect_dismissed';
62
63 /**
64 * After a dismissal nothing is shown for this long; then the next product
65 * the user has not dismissed gets its turn.
66 */
67 const REMIND_AFTER = 30 * DAY_IN_SECONDS;
68
69 /**
70 * A user is asked this many times at most, however many products opted in.
71 */
72 const MAX_DISMISSALS = 3;
73
74 /**
75 * The notice waits this long after the product was installed.
76 */
77 const MINIMUM_INSTALL_AGE = DAY_IN_SECONDS;
78
79 /**
80 * And this long after the module first ran on the site, so an update does
81 * not greet a long-time user with it.
82 */
83 const MINIMUM_SINCE = 2 * DAY_IN_SECONDS;
84
85 /**
86 * When the module first ran on this site.
87 */
88 const SINCE_OPTION = 'themeisle_sdk_ai_connect_since';
89
90
91 /**
92 * Every product that opted in, keyed by product key. Shared by all the
93 * instances so one screen renders ONE notice and ONE modal, however many
94 * Themeisle products the site runs.
95 *
96 * @var array<string, array{product: Product, data: array}>
97 */
98 private static $registered = array();
99
100 /**
101 * Whether the shared hooks were added already.
102 *
103 * @var bool
104 */
105 private static $hooked = false;
106
107 /**
108 * Slug of the product whose internal page is being displayed, if any.
109 *
110 * @var string
111 */
112 private static $internal_product = '';
113
114 /**
115 * Whether this request is the Customizer controls screen.
116 *
117 * @var bool
118 */
119 private static $in_customizer = false;
120
121 /**
122 * Whether this request is the Site Editor (block themes edit their site
123 * there, never in the Customizer).
124 *
125 * @var bool
126 */
127 private static $in_site_editor = false;
128
129 /**
130 * Whether the assets were enqueued on this request.
131 *
132 * @var bool
133 */
134 private static $enqueued = false;
135
136 /**
137 * The product picked for this screen.
138 *
139 * @var array|null
140 */
141 private static $current = null;
142
143 /**
144 * This product's metadata, received from the filter.
145 *
146 * @var array
147 */
148 private $data = array();
149
150 /**
151 * Should we load this module.
152 *
153 * @param Product $product Product object.
154 *
155 * @return bool
156 */
157 public function can_load( $product ) {
158 if ( $this->is_from_partner( $product ) ) {
159 return false;
160 }
161 if ( ! is_admin() ) {
162 return false;
163 }
164 // With the connector already running there is nothing left to offer: no
165 // notice, no plugin-row link, no modal.
166 if ( $this->is_connector_active() ) {
167 return false;
168 }
169
170 $this->data = self::sanitize_metadata( apply_filters( $product->get_key() . '_ai_connect_metadata', array() ), $product );
171
172 return ! empty( $this->data );
173 }
174
175 /**
176 * Keep only what the UI can use. A product that sends no prompt and no use
177 * case has nothing to say, and is treated as not opted in.
178 *
179 * @param mixed $data Raw filter result.
180 * @param Product $product Product object.
181 *
182 * @return array
183 */
184 public static function sanitize_metadata( $data, $product ) {
185 if ( ! is_array( $data ) ) {
186 return array();
187 }
188 $strings = function ( $list, $max ) {
189 $list = is_array( $list ) ? $list : array();
190 $list = array_filter(
191 array_map(
192 function ( $item ) {
193 return is_string( $item ) ? trim( wp_strip_all_tags( $item ) ) : '';
194 },
195 $list
196 )
197 );
198 return array_slice( array_values( $list ), 0, $max );
199 };
200
201 $cases = $strings( isset( $data['notice_cases'] ) ? $data['notice_cases'] : array(), 3 );
202 $prompts = $strings( isset( $data['prompts'] ) ? $data['prompts'] : array(), 5 );
203 if ( empty( $cases ) || empty( $prompts ) ) {
204 return array();
205 }
206
207 $abilities = array();
208 foreach ( $strings( isset( $data['abilities'] ) ? $data['abilities'] : array(), 100 ) as $ability ) {
209 if ( preg_match( '#^[a-z0-9\-]+/[a-z0-9\-/]+$#', $ability ) ) {
210 $abilities[] = $ability;
211 }
212 }
213
214 $prefixes = array();
215 $raw = isset( $data['ability_prefix'] ) ? $data['ability_prefix'] : array();
216 foreach ( $strings( is_string( $raw ) ? array( $raw ) : $raw, 5 ) as $prefix ) {
217 if ( preg_match( '#^[a-z0-9\-]+$#', $prefix ) ) {
218 $prefixes[] = $prefix;
219 }
220 }
221
222 $name = isset( $data['name'] ) && is_string( $data['name'] ) && '' !== trim( $data['name'] ) ? trim( wp_strip_all_tags( $data['name'] ) ) : $product->get_friendly_name();
223
224 $internal_slug = isset( $data['internal_slug'] ) && is_string( $data['internal_slug'] ) ? sanitize_key( $data['internal_slug'] ) : '';
225
226 return array(
227 'name' => $name,
228 'notice_cases' => $cases,
229 'prompts' => $prompts,
230 'abilities' => $abilities,
231 'prefixes' => $prefixes,
232 'internal_slug' => '' !== $internal_slug ? $internal_slug : $product->get_slug(),
233 );
234 }
235
236 /**
237 * Registers the hooks.
238 *
239 * @param Product $product Product to load.
240 *
241 * @return Ai_Connect
242 */
243 public function load( $product ) {
244 $this->product = $product;
245
246 self::$registered[ $product->get_key() ] = array(
247 'product' => $product,
248 'data' => $this->data,
249 );
250
251 if ( $product->is_plugin() ) {
252 add_filter( 'plugin_row_meta', array( $this, 'add_row_meta' ), 10, 2 );
253 }
254
255 if ( self::$hooked ) {
256 return $this;
257 }
258 self::$hooked = true;
259
260 if ( ! get_option( self::SINCE_OPTION ) ) {
261 add_option( self::SINCE_OPTION, time(), '', false );
262 }
263
264 add_action( 'themeisle_internal_page', array( __CLASS__, 'mark_internal_page' ), 10, 2 );
265 add_action( 'admin_notices', array( $this, 'render_notice' ) );
266 add_action( 'in_admin_header', array( $this, 'ensure_notice_hook' ), PHP_INT_MAX );
267 // Late: after the products had their chance to fire themeisle_internal_page.
268 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ), 999 );
269 // admin_footer runs BEFORE admin_print_footer_scripts, so the modal is in
270 // the DOM when the script initialises.
271 add_action( 'admin_footer', array( $this, 'render_modal' ) );
272 // Themes: the Customizer has no admin notices, so the same invitation
273 // goes into its notification area (see ai-connect.js).
274 add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_customizer' ), 999 );
275 add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_modal' ), 1 );
276 add_action( 'wp_ajax_' . self::AJAX_ENABLE, array( $this, 'ajax_enable' ) );
277 add_action( 'wp_ajax_' . self::AJAX_DISMISS, array( $this, 'ajax_dismiss' ) );
278
279 return $this;
280 }
281
282 /**
283 * Remember which product's internal page this is.
284 *
285 * @param string $product_slug Product slug.
286 * @param string $page_slug Page slug.
287 *
288 * @return void
289 */
290 public static function mark_internal_page( $product_slug, $page_slug = '' ) {
291 if ( ! is_string( $product_slug ) || '' !== self::$internal_product ) {
292 return;
293 }
294 // Some products pass their plugin basename ("dir/file.php") instead of
295 // the slug; the directory is the slug either way.
296 if ( false !== strpos( $product_slug, '/' ) ) {
297 $product_slug = dirname( $product_slug );
298 }
299 self::$internal_product = $product_slug;
300 }
301
302 /**
303 * Some products clear every admin_notices callback on their own screens
304 * (remove_all_actions on load-{$hook}), which takes ours with it. Put it
305 * back just before the notices are printed.
306 *
307 * @return void
308 */
309 public function ensure_notice_hook() {
310 if ( false === has_action( 'admin_notices', array( $this, 'render_notice' ) ) ) {
311 add_action( 'admin_notices', array( $this, 'render_notice' ) );
312 }
313 // Before anything prints: the review/opt-in queue picks its notice at
314 // admin_notices and reads this timestamp, so it yields to us today.
315 if ( $this->should_show_notice() ) {
316 self::mark_seen();
317 }
318 }
319
320 /**
321 * Did any product on this site opt in? The Promotions module asks, so the
322 * site gets one invitation to the connector and not two.
323 *
324 * @return bool
325 */
326 public static function has_products() {
327 return ! empty( self::$registered );
328 }
329
330 /**
331 * Was the SDK's own Easy MCP promotion dismissed on this site? It is the
332 * same invitation, so the answer counts here too.
333 *
334 * @return bool
335 */
336 public static function is_promotion_dismissed() {
337 $stored = json_decode( (string) get_option( 'themeisle_sdk_promotions', '{}' ), true );
338 if ( ! is_array( $stored ) ) {
339 return false;
340 }
341
342 return ! empty( $stored['easy-mcp-plugins-install'] ) || ! empty( $stored['easy-mcp-profile'] );
343 }
344
345 /**
346 * Only users who may install AND activate plugins can do what the modal
347 * offers, so nobody else sees any of it.
348 *
349 * @return bool
350 */
351 public static function user_can_connect() {
352 return current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' );
353 }
354
355 /**
356 * Whether this is the plugins list screen.
357 *
358 * @return bool
359 */
360 private static function is_plugins_screen() {
361 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
362
363 return $screen && 'plugins' === $screen->id;
364 }
365
366 /**
367 * Whether this is the WordPress Dashboard (index.php).
368 *
369 * @return bool
370 */
371 private static function is_dashboard_screen() {
372 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
373
374 return $screen && 'dashboard' === $screen->id;
375 }
376
377 /**
378 * Is this one of the theme surfaces: the themes list, the Customizer or
379 * the Site Editor?
380 *
381 * @return bool
382 */
383 private static function is_theme_surface() {
384 if ( self::$in_customizer || self::is_site_editor() ) {
385 return true;
386 }
387 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
388
389 return $screen && 'themes' === $screen->id;
390 }
391
392 /**
393 * Whether this is the Site Editor (site-editor.php). Its admin notices are
394 * printed behind the full-screen editor, so the invitation goes through the
395 * editor's own notices store instead (see ai-connect.js).
396 *
397 * @return bool
398 */
399 private static function is_site_editor() {
400 if ( self::$in_site_editor ) {
401 return true;
402 }
403 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
404 if ( $screen && 'site-editor' === $screen->id ) {
405 self::$in_site_editor = true;
406 }
407
408 return self::$in_site_editor;
409 }
410
411 /**
412 * The product this screen talks about: the owner of the internal page, on
413 * the plugins list the opted-in plugin that was installed first, on the
414 * themes list and in the Customizer the opted-in theme.
415 *
416 * @return array|null { product, data }
417 */
418 private static function current() {
419 // Not cached: products fire themeisle_internal_page from their own
420 // admin_enqueue_scripts callbacks, so an early answer would be a wrong one.
421 self::$current = null;
422
423 if ( '' !== self::$internal_product ) {
424 $editor = self::$in_customizer || self::is_site_editor();
425 foreach ( self::$registered as $entry ) {
426 if ( $entry['product']->get_slug() !== self::$internal_product && $entry['data']['internal_slug'] !== self::$internal_product ) {
427 continue;
428 }
429 // A plugin that counts the editor as its own page (Otter fires
430 // themeisle_internal_page from the block editor) must not take
431 // the Customizer or the Site Editor away from the theme.
432 if ( $editor && ! $entry['product']->is_theme() ) {
433 break;
434 }
435 self::$current = $entry;
436 return self::$current;
437 }
438 if ( ! $editor ) {
439 return self::$current;
440 }
441 }
442
443 $theme_surface = self::is_theme_surface();
444 if ( ! $theme_surface && ! self::is_plugins_screen() ) {
445 return null;
446 }
447
448 $oldest = PHP_INT_MAX;
449 foreach ( self::$registered as $entry ) {
450 if ( $entry['product']->is_theme() !== $theme_surface || self::is_product_dismissed( $entry['product'] ) ) {
451 continue;
452 }
453 $installed = (int) $entry['product']->get_install_time();
454 if ( $installed < $oldest ) {
455 $oldest = $installed;
456 self::$current = $entry;
457 }
458 }
459
460 return self::$current;
461 }
462
463 /**
464 * Is the connector plugin active?
465 *
466 * @return bool
467 */
468 public function is_connector_active() {
469 return $this->is_plugin_active( self::CONNECTOR_SLUG );
470 }
471
472 /**
473 * Is the notice resting for this user? True for REMIND_AFTER after a
474 * dismissal, and for good after MAX_DISMISSALS. Per user, not per site:
475 * another administrator has not seen it yet.
476 *
477 * @return bool
478 */
479 public static function is_dismissed() {
480 $dismissals = self::dismissals();
481 if ( empty( $dismissals ) ) {
482 return false;
483 }
484
485 return count( $dismissals ) >= self::MAX_DISMISSALS || ( time() - max( $dismissals ) ) < self::REMIND_AFTER;
486 }
487
488 /**
489 * What this user dismissed: product key => time. Per user, not per site.
490 *
491 * @return array<string, int>
492 */
493 public static function dismissals() {
494 $stored = get_user_meta( get_current_user_id(), self::DISMISSED_KEY, true );
495 if ( empty( $stored ) ) {
496 return array();
497 }
498 if ( ! is_array( $stored ) ) {
499 // A bare time, from before dismissals were kept per product.
500 return array( '_' => (int) $stored );
501 }
502
503 return array_map( 'intval', $stored );
504 }
505
506 /**
507 * Has this user dismissed this product's notice?
508 *
509 * @param Product $product Product.
510 *
511 * @return bool
512 */
513 private static function is_product_dismissed( $product ) {
514 return array_key_exists( $product->get_key(), self::dismissals() );
515 }
516
517 /**
518 * Whether the notice shows on this request.
519 *
520 * @return bool
521 */
522 public function should_show_notice() {
523 if ( ! self::user_can_connect() || self::is_dismissed() || self::is_promotion_dismissed() ) {
524 return false;
525 }
526 $current = self::current();
527 // A product's own page only ever talks about that product.
528 if ( null === $current || self::is_product_dismissed( $current['product'] ) ) {
529 return false;
530 }
531 if ( ( time() - (int) get_option( self::SINCE_OPTION, time() ) ) < self::MINIMUM_SINCE ) {
532 return false;
533 }
534 // The sale is the one SDK notice that goes first; every other one waits for us.
535 if ( apply_filters( 'themeisle_sdk_is_black_friday_sale', false ) ) {
536 return false;
537 }
538 return ( time() - (int) $current['product']->get_install_time() ) >= self::MINIMUM_INSTALL_AGE;
539 }
540
541 /**
542 * Tell the other SDK notices we were here, so they keep their distance:
543 * the review/opt-in queue waits its usual days after us, and the promotions
544 * their usual weeks after a dismissal. Ours goes first: it is marked before
545 * the notices print, so on the same page view the queue already yields.
546 *
547 * @param bool $dismissed Whether the user dismissed us, or just saw us.
548 *
549 * @return void
550 */
551 public static function mark_seen( $dismissed = false ) {
552 $notifications = get_option( 'themeisle_sdk_notifications', array() );
553 $notifications = is_array( $notifications ) ? $notifications : array();
554 $last_active = isset( $notifications['last_notification_active'] ) ? (int) $notifications['last_notification_active'] : 0;
555 // Once a day is enough: this runs on every page view that shows the notice.
556 if ( $dismissed || ( time() - $last_active ) > DAY_IN_SECONDS ) {
557 $notifications['last_notification_active'] = time();
558 update_option( 'themeisle_sdk_notifications', $notifications );
559 }
560 if ( $dismissed ) {
561 $promotions = json_decode( (string) get_option( 'themeisle_sdk_promotions', '{}' ), true );
562 $promotions = is_array( $promotions ) ? $promotions : array();
563 $promotions['ai-connect'] = time();
564 update_option( 'themeisle_sdk_promotions', wp_json_encode( $promotions ) );
565 }
566 }
567
568 /**
569 * Plugin-row link, next to "View details".
570 *
571 * @param string[] $links Row meta links.
572 * @param string $file Plugin base file.
573 *
574 * @return string[]
575 */
576 public function add_row_meta( $links, $file ) {
577 if ( plugin_basename( $this->product->get_basefile() ) !== $file || ! self::user_can_connect() ) {
578 return $links;
579 }
580
581 $links[] = '<a href="#" class="ti-ai-connect-open" data-ti-ai-connect="' . esc_attr( $this->product->get_key() ) . '">'
582 . self::sparkle()
583 . esc_html( Loader::$labels['ai_connect']['row_link'] ) . '</a>';
584
585 return $links;
586 }
587
588 /**
589 * The notice.
590 *
591 * @return void
592 */
593 public function render_notice() {
594 // The Site Editor prints admin notices behind its full-screen app; the
595 // invitation reaches it through the editor's notices store instead.
596 if ( self::is_site_editor() || ! $this->should_show_notice() ) {
597 return;
598 }
599 // Some products only fire themeisle_internal_page while their scripts
600 // print, after admin_enqueue_scripts: the page was unknown when assets
601 // were decided. Both are footer-safe, so enqueue them now.
602 $this->enqueue();
603
604 $current = self::current();
605 $labels = Loader::$labels['ai_connect'];
606 $do = self::join_cases( $current['data']['notice_cases'] );
607 ?>
608 <?php if ( '' !== self::$internal_product && ! self::is_dashboard_screen() ) : ?>
609 <?php // The product's own page: every other notice steps aside while ours shows. Not on the WP Dashboard, which some themes claim as their page but which belongs to everyone. ?>
610 <style>.notice:not(.ti-ai-notice):not(.themeisle-sdk-license-notice), .updated:not(.ti-ai-notice), .update-nag { display: none !important; }</style>
611 <?php else : ?>
612 <?php // Shared screens: one SDK voice at a time. Only the SDK's own review, opt-in, promotion and sale notices step aside. ?>
613 <style>.themeisle-sdk-notice, .ti-sdk-om-notice, .ti-sdk-rop-notice, .themeisle-sale { display: none !important; }</style>
614 <?php endif; ?>
615 <div class="notice notice-info is-dismissible ti-ai-notice" data-ti-ai-notice>
616 <div class="ti-ai-notice-inner">
617 <p>
618 <strong><?php echo esc_html( sprintf( $labels['notice_title'], $current['data']['name'] ) ); ?></strong>
619 <?php echo esc_html( sprintf( $labels['notice_text'], $do ) ); ?>
620 </p>
621 <a href="#" class="button ti-ai-connect-open" data-ti-ai-connect="<?php echo esc_attr( $current['product']->get_key() ); ?>"><?php echo self::sparkle(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static SVG. ?><?php echo esc_html( $labels['notice_button'] ); ?></a>
622 </div>
623 </div>
624 <?php
625 }
626
627 /**
628 * "a, b or c".
629 *
630 * @param string[] $cases Use cases.
631 *
632 * @return string
633 */
634 private static function join_cases( $cases ) {
635 $last = array_pop( $cases );
636
637 return empty( $cases ) ? (string) $last : sprintf( Loader::$labels['ai_connect']['cases_join'], implode( ', ', $cases ), $last );
638 }
639
640 /**
641 * Whether anything of ours is on this screen.
642 *
643 * @return bool
644 */
645 private function is_relevant_screen() {
646 return self::user_can_connect() && ( ( ! self::$in_customizer && self::is_plugins_screen() ) || $this->should_show_notice() );
647 }
648
649 /**
650 * Customizer controls: same assets and modal, when the notice is due.
651 *
652 * @return void
653 */
654 public function enqueue_customizer() {
655 self::$in_customizer = true;
656 $this->enqueue();
657 }
658
659 /**
660 * Assets, only where the link or the notice can be.
661 *
662 * @return void
663 */
664 public function enqueue() {
665 // The Customizer fires admin_enqueue_scripts too (WP_Customize_Widgets
666 // does, from inside customize_controls_enqueue_scripts), so this can run
667 // before enqueue_customizer(): recognise the screen either way.
668 if ( did_action( 'customize_controls_init' ) ) {
669 self::$in_customizer = true;
670 }
671 if ( self::$enqueued || ! $this->is_relevant_screen() ) {
672 return;
673 }
674 self::$enqueued = true;
675
676 $handle = 'themeisle-sdk-ai-connect';
677 $base = $this->get_sdk_uri() . 'assets/js/build/ai_connect/';
678 $asset = dirname( dirname( __DIR__ ) ) . '/assets/js/build/ai_connect/ai-connect.asset.php';
679 $meta = is_readable( $asset ) ? require $asset : array(
680 'dependencies' => array(),
681 'version' => Loader::get_version(),
682 );
683
684 wp_enqueue_style( $handle, $base . 'ai-connect.css', array(), $meta['version'] );
685 wp_enqueue_script( $handle, $base . 'ai-connect.js', $meta['dependencies'], $meta['version'], true );
686
687 $endpoint = rest_url( self::CONNECTOR_ROUTE );
688 $products = array();
689 foreach ( self::$registered as $key => $entry ) {
690 $products[ $key ] = array(
691 'name' => $entry['data']['name'],
692 'prompts' => $entry['data']['prompts'],
693 );
694 }
695 $current = self::current();
696 $labels = Loader::$labels['ai_connect'];
697 // The button says what will happen: install and activate, or just activate.
698 $labels['enable'] = $this->enable_label();
699 $labels['enabling'] = $this->is_plugin_installed( self::CONNECTOR_SLUG ) ? $labels['activating'] : $labels['enabling'];
700 $pitch = array();
701 $surface = '';
702 // The Customizer and the Site Editor have no usable admin notices: the
703 // script delivers the invitation through their own notification APIs.
704 if ( self::$in_customizer ) {
705 $surface = 'customizer';
706 } elseif ( self::is_site_editor() ) {
707 $surface = 'site_editor';
708 }
709 if ( '' !== $surface && null !== $current && $this->should_show_notice() ) {
710 self::mark_seen();
711 $pitch = array(
712 'title' => sprintf( $labels['notice_title'], $current['data']['name'] ),
713 'text' => sprintf( $labels['notice_text'], self::join_cases( $current['data']['notice_cases'] ) ),
714 'icon' => self::sparkle(),
715 );
716 }
717
718 wp_localize_script(
719 $handle,
720 'themeisleSDKAiConnect',
721 array(
722 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
723 'nonce' => wp_create_nonce( self::NONCE ),
724 'actions' => array(
725 'enable' => self::AJAX_ENABLE,
726 'dismiss' => self::AJAX_DISMISS,
727 ),
728 'endpoint' => $endpoint,
729 'links' => self::connect_links( $endpoint ),
730 'products' => $products,
731 'current' => $current ? $current['product']->get_key() : ( empty( $products ) ? '' : key( $products ) ),
732 'labels' => $labels,
733 'pitch' => $pitch,
734 'surface' => $surface,
735 )
736 );
737 }
738
739 /**
740 * One-click deep links. They mirror Easy MCP's own
741 * Admin_Page::build_connect_url() so they can be offered before it runs.
742 * ChatGPT cannot be pre-filled: the user pastes the URL. Cursor's link wants
743 * its `config` as Base64 JSON; the script appends it (window.btoa), because
744 * PHP's Base64 function is refused by the WordPress.org plugin check in
745 * every product that bundles the SDK, whatever the reason for the call.
746 *
747 * @param string $endpoint The site's MCP URL.
748 *
749 * @return array<string, string>
750 */
751 public static function connect_links( $endpoint ) {
752 // get_bloginfo() returns the name escaped for display; a URL needs the real one.
753 $title = trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) );
754 $name = '' !== $title ? $title . ' WordPress' : 'WordPress';
755
756 return array(
757 'claude' => 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=' . rawurlencode( $name ) . '&connectorUrl=' . rawurlencode( $endpoint ),
758 'chatgpt' => 'https://chatgpt.com/plugins#settings/Connectors?create-connector=true&redirectAfter=%2Fplugins',
759 'cursor' => 'cursor://anysphere.cursor-deeplink/mcp/install?name=' . rawurlencode( $name ),
760 );
761 }
762
763 /**
764 * The modal. Printed once, whatever the number of opted-in products.
765 *
766 * @return void
767 */
768 public function render_modal() {
769 if ( ! $this->is_relevant_screen() ) {
770 return;
771 }
772 $labels = Loader::$labels['ai_connect'];
773 ?>
774 <div id="ti-ai-connect" class="ti-ai-modal" hidden>
775 <div class="ti-ai-backdrop" data-close></div>
776 <div class="ti-ai-dialog" role="dialog" aria-modal="true" aria-labelledby="ti-ai-title" data-enabled="0" tabindex="-1">
777 <button type="button" class="ti-ai-close" data-close aria-label="<?php echo esc_attr( $labels['close'] ); ?>"><span class="dashicons dashicons-no-alt"></span></button>
778
779 <p class="ti-ai-eyebrow"><?php echo self::sparkle(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static SVG. ?><?php echo esc_html( $labels['eyebrow'] ); ?></p>
780 <h2 id="ti-ai-title" data-title></h2>
781 <p class="ti-ai-lead" data-lead></p>
782
783 <div class="ti-ai-enable">
784 <button type="button" class="button button-primary button-hero" data-enable><?php echo esc_html( $this->enable_label() ); ?></button>
785 <p class="ti-ai-hint ti-ai-when-off" data-note></p>
786 <span class="ti-ai-enabled-badge"><span class="dashicons dashicons-yes-alt"></span><?php echo esc_html( $labels['enabled'] ); ?></span>
787 <p class="ti-ai-error" data-error role="alert" hidden></p>
788 </div>
789
790 <label class="ti-ai-label" for="ti-ai-endpoint"><?php echo esc_html( $labels['url_label'] ); ?></label>
791 <div class="ti-ai-copyrow">
792 <input id="ti-ai-endpoint" type="text" class="regular-text code" readonly data-endpoint value="">
793 <button type="button" class="button" data-copy="#ti-ai-endpoint"><?php echo esc_html( $labels['copy'] ); ?></button>
794 </div>
795
796 <h3><?php echo esc_html( $labels['connect_heading'] ); ?></h3>
797 <div class="ti-ai-clients">
798 <a class="button ti-ai-client" data-client="claude" target="_blank" rel="noopener noreferrer" href="#"><?php echo esc_html( $labels['connect_claude'] ); ?></a>
799 <a class="button ti-ai-client" data-client="chatgpt" target="_blank" rel="noopener noreferrer" href="#"><?php echo esc_html( $labels['connect_chatgpt'] ); ?></a>
800 <a class="button ti-ai-client" data-client="cursor" href="#"><?php echo esc_html( $labels['connect_cursor'] ); ?></a>
801 </div>
802 <p class="ti-ai-hint ti-ai-when-off"><?php echo esc_html( $labels['hint_off'] ); ?> <?php echo esc_html( $labels['more_agents'] ); ?> <a class="ti-ai-more" href="<?php echo esc_url( self::INTEGRATIONS_URL ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $labels['more_agents_link'] ); ?></a></p>
803 <p class="ti-ai-hint ti-ai-when-on"><?php echo esc_html( $labels['hint_on'] ); ?> <?php echo esc_html( $labels['more_agents'] ); ?> <a class="ti-ai-more" href="<?php echo esc_url( self::INTEGRATIONS_URL ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $labels['more_agents_link'] ); ?></a></p>
804
805 <h3 class="ti-ai-when-off"><?php echo esc_html( $labels['prompts_off'] ); ?></h3>
806 <h3 class="ti-ai-when-on"><?php echo esc_html( $labels['prompts_on'] ); ?></h3>
807 <ul class="ti-ai-prompts" data-prompts></ul>
808 </div>
809 </div>
810 <?php
811 }
812
813 /**
814 * Refuse an AJAX call that is not ours to serve.
815 *
816 * @return void
817 */
818 private function guard_ajax() {
819 check_ajax_referer( self::NONCE, 'nonce' );
820 if ( ! self::user_can_connect() ) {
821 wp_send_json_error( array( 'message' => Loader::$labels['ai_connect']['error_permission'] ), 403 );
822 }
823 }
824
825 /**
826 * Dismiss the notice for this user.
827 *
828 * @return void
829 */
830 public function ajax_dismiss() {
831 $this->guard_ajax();
832 $key = isset( $_POST['product'] ) ? sanitize_key( wp_unslash( $_POST['product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard_ajax().
833 if ( ! isset( self::$registered[ $key ] ) ) {
834 $key = '_';
835 }
836 $dismissals = self::dismissals();
837 $dismissals[ $key ] = time();
838 update_user_meta( get_current_user_id(), self::DISMISSED_KEY, $dismissals );
839 self::mark_seen( true );
840 wp_send_json_success();
841 }
842
843 /**
844 * Install (when missing) and activate the connector, then switch the
845 * product's abilities on in it.
846 *
847 * @return void
848 */
849 public function ajax_enable() {
850 $this->guard_ajax();
851
852 $key = isset( $_POST['product'] ) ? sanitize_key( wp_unslash( $_POST['product'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified in guard_ajax().
853 $entry = isset( self::$registered[ $key ] ) ? self::$registered[ $key ] : null;
854
855 $result = $this->install_and_activate();
856 if ( is_wp_error( $result ) ) {
857 wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 );
858 }
859
860 if ( null !== $entry ) {
861 self::enable_abilities( array_merge( $entry['data']['abilities'], self::abilities_with_prefix( $entry['data']['prefixes'] ) ) );
862 }
863
864 $endpoint = rest_url( self::CONNECTOR_ROUTE );
865 wp_send_json_success(
866 array(
867 'endpoint' => $endpoint,
868 'links' => self::connect_links( $endpoint ),
869 )
870 );
871 }
872
873 /**
874 * Install from WordPress.org when missing, then activate. Both steps are
875 * skipped when already done, so calling it twice is harmless.
876 *
877 * @return true|\WP_Error
878 */
879 public function install_and_activate() {
880 if ( $this->is_connector_active() ) {
881 return true;
882 }
883
884 require_once ABSPATH . 'wp-admin/includes/plugin.php';
885
886 if ( ! $this->is_plugin_installed( self::CONNECTOR_SLUG ) ) {
887 require_once ABSPATH . 'wp-admin/includes/file.php';
888 if ( ! function_exists( 'plugins_api' ) ) {
889 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
890 }
891 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
892
893 $api = plugins_api(
894 'plugin_information',
895 array(
896 'slug' => self::CONNECTOR_SLUG,
897 'fields' => array( 'sections' => false ),
898 )
899 );
900 if ( is_wp_error( $api ) ) {
901 return $api;
902 }
903 if ( ! is_object( $api ) || empty( $api->download_link ) ) {
904 return new \WP_Error( 'themeisle_ai_connect_install_failed', Loader::$labels['ai_connect']['error_install'] );
905 }
906
907 $upgrader = new \Plugin_Upgrader( new \WP_Ajax_Upgrader_Skin() );
908 $installed = $upgrader->install( $api->download_link );
909 if ( is_wp_error( $installed ) ) {
910 return $installed;
911 }
912 if ( true !== $installed ) {
913 $errors = $upgrader->skin->get_errors();
914 return is_wp_error( $errors ) && $errors->has_errors() ? $errors : new \WP_Error( 'themeisle_ai_connect_install_failed', Loader::$labels['ai_connect']['error_install'] );
915 }
916 wp_clean_plugins_cache();
917 }
918
919 $activated = activate_plugin( self::CONNECTOR_FILE );
920 if ( is_wp_error( $activated ) ) {
921 return $activated;
922 }
923 self::disarm_connector_setup_redirect();
924
925 return true;
926 }
927
928 /**
929 * The person is in the modal, about to copy the MCP URL and connect an
930 * agent: Easy MCP's setup-wizard redirect, armed by its activation hook for
931 * the next admin page load, would take them away from that. Disarm it; the
932 * wizard is still offered on Easy MCP's own page.
933 *
934 * @return void
935 */
936 public static function disarm_connector_setup_redirect() {
937 delete_transient( self::CONNECTOR_REDIRECT_TRANSIENT );
938 }
939
940 /**
941 * Switch the product's abilities on in the connector, keeping whatever the
942 * site owner enabled before. Only names that are really registered are
943 * added, and nothing is ever removed.
944 *
945 * @param string[] $abilities Ability names declared by the product.
946 *
947 * @return string[] The names that were added.
948 */
949 public static function enable_abilities( $abilities ) {
950 if ( empty( $abilities ) || ! current_user_can( 'manage_options' ) ) {
951 return array();
952 }
953 // A name can come from both the explicit list and the prefix.
954 $abilities = array_values( array_unique( array_filter( $abilities, 'is_string' ) ) );
955 if ( function_exists( 'wp_get_abilities' ) ) {
956 $abilities = array_values( array_intersect( $abilities, array_keys( (array) wp_get_abilities() ) ) );
957 }
958
959 $enabled = get_option( self::ABILITIES_OPTION, array() );
960 $enabled = is_array( $enabled ) ? $enabled : array();
961 $added = array_values( array_diff( $abilities, $enabled ) );
962 if ( ! empty( $added ) ) {
963 update_option( self::ABILITIES_OPTION, array_values( array_merge( $enabled, $added ) ) );
964 }
965
966 return $added;
967 }
968
969 /**
970 * The registered abilities under the given name prefixes, minus the ones
971 * the product registered with meta 'ai_connect' => false.
972 *
973 * @param string[] $prefixes Ability name prefixes, without the slash.
974 *
975 * @return string[]
976 */
977 public static function abilities_with_prefix( $prefixes ) {
978 if ( empty( $prefixes ) || ! function_exists( 'wp_get_abilities' ) ) {
979 return array();
980 }
981 $names = array();
982 foreach ( (array) wp_get_abilities() as $name => $ability ) {
983 if ( ! in_array( strtok( (string) $name, '/' ), $prefixes, true ) ) {
984 continue;
985 }
986 if ( is_object( $ability ) && method_exists( $ability, 'get_meta_item' ) && false === $ability->get_meta_item( 'ai_connect', true ) ) {
987 continue;
988 }
989 $names[] = (string) $name;
990 }
991 return $names;
992 }
993
994 /**
995 * The Enable button's label: the plugin is installed and only needs
996 * activating, or it is fetched from WordPress.org first.
997 *
998 * @return string
999 */
1000 private function enable_label() {
1001 $labels = Loader::$labels['ai_connect'];
1002
1003 return $this->is_plugin_installed( self::CONNECTOR_SLUG ) ? $labels['enable_installed'] : $labels['enable'];
1004 }
1005
1006 /**
1007 * The sparkle icon. Dashicons has no AI icon.
1008 *
1009 * @return string
1010 */
1011 public static function sparkle() {
1012 return '<svg class="ti-ai-sparkle" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false">'
1013 . '<path d="M10 2l1.6 4.4L16 8l-4.4 1.6L10 14l-1.6-4.4L4 8l4.4-1.6L10 2z"/><path d="M16 12l.8 2.2L19 15l-2.2.8L16 18l-.8-2.2L13 15l2.2-.8L16 12z"/>'
1014 . '<path d="M4 12l.6 1.6L6.2 14.2l-1.6.6L4 16.4l-.6-1.6-1.6-.6 1.6-.6L4 12z"/></svg>';
1015 }
1016
1017 /**
1018 * Forget everything. Tests only.
1019 *
1020 * @return void
1021 */
1022 public static function reset() {
1023 self::$registered = array();
1024 self::$hooked = false;
1025 self::$internal_product = '';
1026 self::$in_customizer = false;
1027 self::$in_site_editor = false;
1028 delete_option( self::SINCE_OPTION );
1029 self::$current = null;
1030 self::$enqueued = false;
1031 }
1032 }
1033