PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / _inc / lib / admin-pages / class-jetpack-ai-page.php

class-jetpack-ai-page.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at _inc/lib/admin-pages/class-jetpack-ai-page.php

709 lines 25.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack AI admin page.
4 *
5 * Registers the "AI" submenu item under Jetpack and mounts the React-based
6 * MCP settings interface.
7 *
8 * @package automattic/jetpack
9 */
10
11 use Automattic\Jetpack\Admin_UI\Admin_Menu;
12 use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13 use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15 use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16 use Automattic\Jetpack\Modules;
17 use Automattic\Jetpack\Redirect;
18 use Automattic\Jetpack\Status;
19 use Automattic\Jetpack\Status\Host;
20 use Automattic\Jetpack\Terms_Of_Service;
21 use Automattic\Jetpack\Tracking;
22 use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit( 0 );
26 }
27
28 require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
29 require_once dirname( __DIR__ ) . '/class-jetpack-ai-settings.php';
30
31 /**
32 * Builds the Jetpack AI admin page and its sidebar menu entry.
33 */
34 class Jetpack_AI_Page {
35
36 /**
37 * The wp-build route's page id.
38 *
39 * Deliberately not the `jetpack-ai` menu slug: the generated standalone page.php
40 * intercepts `admin_init` for its own id and exits, which would bypass wp-admin's
41 * chrome entirely. The generated wp-admin enqueue matches the screen ID against this.
42 *
43 * @var string
44 */
45 const WP_BUILD_PAGE_ID = 'jetpack-ai-hub';
46
47 /**
48 * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
49 *
50 * @var string|null
51 */
52 private static $wp_build_original_screen_id = null;
53
54 /**
55 * Whether this request renders through wp-build.
56 *
57 * Checks the render function too: if the build output is missing, the request would
58 * otherwise get no bundle at all now that the legacy entry is gone.
59 *
60 * @since 16.3
61 *
62 * @return bool
63 */
64 public static function should_render_wp_build() {
65 return function_exists( 'jetpack_plugin_jetpack_ai_hub_wp_admin_render_page' );
66 }
67
68 /**
69 * Whether the current request targets the AI Hub admin page.
70 *
71 * @since 16.3
72 *
73 * @return bool
74 */
75 private static function is_ai_admin_request() {
76 if ( ! is_admin() ) {
77 return false;
78 }
79
80 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
81 return isset( $_GET['page'] ) && 'jetpack-ai' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
82 }
83
84 /**
85 * Load wp-build for the AI Hub page.
86 *
87 * Scoped to this page so WP_Build_Polyfills does not replace core scripts everywhere else.
88 *
89 * @since 16.3
90 *
91 * @return void
92 */
93 public static function maybe_load_wp_build() {
94 if ( ! self::is_ai_admin_request() ) {
95 return;
96 }
97
98 $build_index = JETPACK__PLUGIN_DIR . 'build/build.php';
99 if ( ! file_exists( $build_index ) ) {
100 return;
101 }
102
103 // Hooked on either side of the require, so the alias holds only for the generated
104 // enqueue check it registers at the same priority.
105 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
106 require_once $build_index;
107 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
108
109 // wp-build hooks module registration to wp_default_scripts, which has already fired by
110 // admin_menu — call it directly or the init module never reaches the import map.
111 if ( function_exists( 'jetpack_plugin_register_script_modules' ) ) {
112 jetpack_plugin_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
113 }
114
115 if ( class_exists( WP_Build_Polyfills::class ) ) {
116 // wp-rich-text is needed because the Scheduled tasks tab reaches @wordpress/dataviews
117 // via @wordpress/ui, whose dataform controls unlock rich-text's privateApis at module
118 // scope. WP 6.9 exports none, so without it the bundle throws and the page is blank.
119 WP_Build_Polyfills::register(
120 'jetpack',
121 array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
122 );
123 }
124 }
125
126 /**
127 * Point the screen ID at the wp-build page while its generated enqueue check runs.
128 *
129 * @since 16.3
130 *
131 * @return void
132 */
133 public static function alias_screen_id_for_wp_build() {
134 $screen = get_current_screen();
135 if ( ! $screen ) {
136 return;
137 }
138
139 self::$wp_build_original_screen_id = $screen->id;
140 $screen->id = self::WP_BUILD_PAGE_ID;
141 }
142
143 /**
144 * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
145 *
146 * @since 16.3
147 *
148 * @return void
149 */
150 public static function restore_screen_id_after_wp_build() {
151 $screen = get_current_screen();
152 if ( ! $screen || null === self::$wp_build_original_screen_id ) {
153 return;
154 }
155
156 $screen->id = self::$wp_build_original_screen_id;
157 self::$wp_build_original_screen_id = null;
158 }
159
160 /**
161 * Register the page and its page-specific hooks.
162 *
163 * The AI Hub owns its full React layout, so it does not need the legacy
164 * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
165 * lets WordPress.com Simple load the same page without replacing its
166 * request-wide Jetpack_Admin_Page compatibility stub.
167 */
168 public function add_actions() {
169 $is_offline_mode = ( new Status() )->is_offline_mode();
170
171 if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
172 return;
173 }
174
175 if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
176 return;
177 }
178
179 $hook = $this->get_page_hook();
180 if ( ! $hook ) {
181 return;
182 }
183
184 add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
185
186 // Preserve the standalone Jetpack page's existing base stylesheet. Simple
187 // never loaded it for the Hub because it conflicts with wpcom admin pages.
188 if ( ! ( new Host() )->is_wpcom_simple() ) {
189 add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
190 }
191
192 $this->add_page_actions( $hook );
193 }
194
195 /**
196 * Register the "AI" submenu under the Jetpack top-level menu.
197 *
198 * @return string|false Hook returned by Admin_Menu::add_menu().
199 */
200 public function get_page_hook() {
201 return Admin_Menu::add_menu(
202 // "Jetpack AI" is a product name and should not be translated.
203 'Jetpack AI',
204 'Jetpack AI',
205 'manage_options',
206 'jetpack-ai',
207 array( $this, 'render' ),
208 null,
209 array(
210 'product' => 'jetpack-ai',
211 'key' => 'jetpack-ai',
212 )
213 );
214 }
215
216 /**
217 * Attach page-specific actions.
218 *
219 * @param string $hook The page hook returned by get_page_hook().
220 */
221 public function add_page_actions( $hook ) {
222 add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
223 }
224
225 /**
226 * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
227 */
228 public function admin_styles() {
229 $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
230
231 wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
232 wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
233 wp_style_add_data( 'jetpack-admin', 'suffix', $min );
234 }
235
236 /**
237 * Request the existing Agents Manager shell for this page.
238 */
239 public function load_agents_manager() {
240 if ( ! self::is_scheduled_tasks_enabled() ) {
241 return;
242 }
243
244 Agents_Manager::init();
245
246 add_filter( 'agents_manager_should_load', '__return_true' );
247 add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
248 add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
249 add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
250 }
251
252 /**
253 * Use the generic WP Orchestrator agent in AI Hub.
254 *
255 * @return string Agent ID.
256 */
257 public function get_agents_manager_agent_id() {
258 return 'wp-orchestrator';
259 }
260
261 /**
262 * Add the AI Hub provider that supplies scheduled task starter prompts.
263 *
264 * @param array $providers Existing provider module URLs.
265 * @return array Updated provider module URLs.
266 */
267 public function add_scheduled_tasks_provider( $providers ) {
268 $providers[] = add_query_arg(
269 'ver',
270 JETPACK__VERSION,
271 plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
272 );
273
274 return $providers;
275 }
276
277 /**
278 * Customize Agents Manager's empty view for the Scheduled tasks page.
279 *
280 * @param array $data Existing Agents Manager data.
281 * @return array Updated Agents Manager data.
282 */
283 public function add_scheduled_tasks_data( $data ) {
284 $current_user = wp_get_current_user();
285
286 $data['emptyViewHeading'] = sprintf(
287 /* translators: %s: Current user's display name. */
288 __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
289 $current_user->display_name
290 );
291 $data['emptyViewHelp'] = __( 'Got a different request? Ask away.', 'jetpack' );
292 $data['scheduledTaskEmptyViewSuggestions'] = array(
293 array(
294 'id' => 'create-daily-reminder',
295 'label' => __( 'Create a daily reminder', 'jetpack' ),
296 'prompt' => __( 'Create a daily reminder', 'jetpack' ),
297 'autoSubmit' => true,
298 ),
299 array(
300 'id' => 'draft-weekly-post',
301 'label' => __( 'Draft a weekly post', 'jetpack' ),
302 'prompt' => __( 'Draft a weekly post', 'jetpack' ),
303 'autoSubmit' => true,
304 ),
305 array(
306 'id' => 'schedule-monthly-report',
307 'label' => __( 'Schedule a monthly report', 'jetpack' ),
308 'prompt' => __( 'Schedule a monthly report', 'jetpack' ),
309 'autoSubmit' => true,
310 ),
311 );
312
313 return $data;
314 }
315
316 /**
317 * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
318 *
319 * @since 16.2
320 *
321 * @return bool
322 */
323 private static function is_scheduled_tasks_enabled() {
324 return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
325 }
326
327 /**
328 * Enqueue scripts and styles for the AI admin page.
329 */
330 public function page_admin_scripts() {
331 // wp-build owns the route bundle and its dependencies; this handle only carries the
332 // inline settings below, so the plugin version is version enough to bust its cache.
333 $script_version = JETPACK__VERSION;
334
335 $blog_id = Connection_Manager::get_site_id( true );
336 $status = new Status();
337 $site_suffix = $status->get_site_suffix();
338 // Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
339 // include '::' for subdirectory installs, which would break the URL. This matches
340 // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
341 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
342 $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
343
344 /*
345 * On Atomic link to WPCOM activity log; on self-hosted link to the local
346 * wp-admin page, which only exists while the `activity-log` module is on.
347 * An empty URL hides the row rather than linking to an unregistered page.
348 */
349 if ( ( new Host() )->is_woa_site() ) {
350 $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
351 } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
352 $activity_log_url = admin_url( 'admin.php?page=jetpack-activity-log' );
353 } else {
354 $activity_log_url = '';
355 }
356
357 /*
358 * Link SEO settings to the dedicated Jetpack SEO page where it exists,
359 * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
360 * filter is required in addition to the cohort check: is_seo_surface_visible()
361 * alone returns true on all of wpcom-platform even while the flag is off —
362 * it answers only the cohort half, and page registration requires both
363 * (see packages/seo Initializer::init()).
364 */
365 $seo_settings_url = admin_url( 'admin.php?page=jetpack#/traffic' );
366 $is_internal_test = jetpack_is_internal_testing_environment();
367 $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
368 if (
369 // The exact-symbol guard matters: the autoloader can select an older
370 // jetpack-seo copy from another plugin that has the class but not
371 // this method, and class_exists alone would then fatal here.
372 method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
373 && (bool) apply_filters( 'rsm_jetpack_seo', false )
374 && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
375 ) {
376 $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
377 }
378
379 // The route bundle is registered by wp-build; this handle exists only to carry the
380 // inline settings below, which the app reads from `window.jetpackAiSettings`.
381 wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
382 wp_enqueue_script( 'jetpack-ai-admin' );
383
384 // Registered on every admin page but only enqueued when depended on, and the esbuild
385 // bundles don't pull it in — without it every string renders untranslated.
386 if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
387 wp_enqueue_script( 'wp-jp-i18n-loader' );
388 }
389
390 // The Tracks sender (w.js); without it, queued events never leave the
391 // browser. Consent-gated like the other surfaces that load it.
392 $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
393 if ( $can_send_tracks ) {
394 Tracking::register_tracks_functions_scripts( true );
395 }
396
397 // Unconditional, as on the other Jetpack admin pages: the connection store
398 // reads it, and only Scheduled tasks used to need it here.
399 Connection_Initial_State::render_script( 'jetpack-ai-admin' );
400
401 $host = new Host();
402 $has_my_jetpack = self::has_my_jetpack();
403 $is_offline_mode = $status->is_offline_mode();
404
405 /**
406 * Filters the host-specific AI Hub configuration.
407 *
408 * @since 16.2
409 *
410 * @param array $config AI Hub host configuration.
411 */
412 $config = apply_filters(
413 'jetpack_ai_admin_config',
414 array(
415 // The Overview and Features views launch on non-VIP self-hosted sites first.
416 // Keep this filterable so hosts can close them independently.
417 'showGatedViews' => ! $host->is_vip_site()
418 && ( ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ) ),
419 'showA12sBadge' => $host->is_woa_site() && $is_internal_test,
420 // The same verdicts the feature-settings endpoint reports. That call
421 // exists for the AI Features toggles; the notice must not wait on it.
422 'isUserConnected' => Jetpack_AI_Settings::user_is_connected(),
423 'isConnected' => Jetpack_AI_Settings::site_is_connected(),
424 'hostAllowsAi' => Jetpack_AI_Settings::host_allows_ai(),
425 'masterEnabled' => Jetpack_AI_Settings::is_master_enabled(),
426 // The route, not a flag: each one documents a different hook.
427 'masterForcedOff' => Jetpack_AI_Settings::get_master_forced_off_route(),
428 'isOfflineMode' => $is_offline_mode,
429 // These three answer one question; a filter changing one alone leaves
430 // a label pointing at a page that is not there.
431 'hasMyJetpack' => $has_my_jetpack,
432 'userConnectionUrl' => $has_my_jetpack
433 ? 'admin.php?page=my-jetpack#/connection'
434 : 'admin.php?page=jetpack#/connect-user',
435 'manageUrl' => $has_my_jetpack
436 ? 'admin.php?page=my-jetpack#/products'
437 : 'admin.php?page=jetpack_modules',
438 'mcpSettingsApi' => array(
439 'path' => '/wpcom/v2/jetpack-ai/mcp-settings',
440 'format' => 'jetpack',
441 ),
442 )
443 );
444
445 $show_gated_views = ! empty( $config['showGatedViews'] );
446
447 $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
448
449 $settings = array(
450 'blogId' => $blog_id ? (int) $blog_id : 0,
451 'activityLogUrl' => $activity_log_url,
452 'seoSettingsUrl' => $seo_settings_url,
453 'siteAdminUrl' => admin_url(),
454 'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
455 'manageUrl' => esc_url_raw( $config['manageUrl'] ?? '' ),
456 'hasMyJetpack' => ! empty( $config['hasMyJetpack'] ),
457 'isConnected' => ! empty( $config['isConnected'] ),
458 'hostAllowsAi' => ! empty( $config['hostAllowsAi'] ),
459 'masterEnabled' => ! empty( $config['masterEnabled'] ),
460 'masterForcedOff' => in_array(
461 $config['masterForcedOff'] ?? '',
462 array(
463 Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
464 Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
465 Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
466 ),
467 true
468 ) ? $config['masterForcedOff'] : '',
469 'isOfflineMode' => ! empty( $config['isOfflineMode'] ),
470 'apiRoot' => esc_url_raw( rest_url() ),
471 'apiNonce' => wp_create_nonce( 'wp_rest' ),
472 'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
473 // Images ship from the plugin directory, so the plugin version is what busts their cache.
474 'assetsVersion' => JETPACK__VERSION,
475 // The redirect entry bakes in the jetpack_ai_yearly product and
476 // a post-checkout return to this page, so both can be
477 // retargeted without shipping a code change.
478 'upgradeUrl' => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
479 // The purchase granting AI — the usage card only uses it to pick
480 // the right loading-skeleton shape before the usage fetch lands.
481 // Only looked up when a gated view can render the card.
482 'planName' => $plan_info['name'],
483 'showFeaturesView' => $show_gated_views,
484 'showA12sBadge' => ! empty( $config['showA12sBadge'] ),
485 // The tab and its Agents Manager sidebar ship disabled by default.
486 'featureFlags' => array(
487 Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
488 ),
489 // The usage endpoint proxies as the current user, which needs
490 // their own WordPress.com account linked — not just the site.
491 'isUserConnected' => ! empty( $config['isUserConnected'] ),
492 // Tracks audience properties for the jetpack_mcp_* events, per the
493 // Tracks standards for AI product events (AIINT-586). The client
494 // sends them as the strings 'true'/'false' (AIINT-576).
495 'isA11n' => self::is_current_user_automattician(),
496 'isTest' => $is_internal_test,
497 // Identity for Tracks; the lookup can call WordPress.com on a
498 // cache miss, so it shares the sender's guard.
499 'tracksUserData' => $can_send_tracks ? self::get_tracks_user_data() : null,
500 'mcpSettingsApi' => $config['mcpSettingsApi'] ?? array(),
501 );
502
503 wp_add_inline_script(
504 'jetpack-ai-admin',
505 'var jetpackAiSettings = ' . wp_json_encode(
506 $settings,
507 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
508 ) . ';',
509 'before'
510 );
511
512 /*
513 * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
514 * event-fire time and attaches it to every Tracks event fired from this page.
515 * Without it, JS-fired events from self-hosted sites carry no blog_id — the
516 * Tracks pixel cannot resolve the site — so the events cannot be joined to
517 * plan or site data. Mirrors Connection\Initial_State::render().
518 */
519 wp_add_inline_script(
520 'jetpack-ai-admin',
521 sprintf(
522 'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
523 absint( $blog_id )
524 ),
525 'before'
526 );
527 }
528
529 /**
530 * Connected-user identity for Tracks; null when no WordPress.com account is
531 * linked. Two keys only, so email and locale stay out of the page HTML.
532 *
533 * @return array{userid:int, username:string}|null
534 */
535 private static function get_tracks_user_data() {
536 $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
537 if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
538 return null;
539 }
540
541 return array(
542 'userid' => (int) $identity['userid'],
543 'username' => (string) $identity['username'],
544 );
545 }
546
547 /**
548 * Whether the current user is an Automattician.
549 *
550 * Identity check for the Tracks `is_a11n` audience property — it answers
551 * "who is this", not "may they use the tool", so it deliberately does not
552 * consult the MCP allowlist: allowlisted external testers are not a11ns.
553 *
554 * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
555 * Self-hosted Jetpack has no platform check; there the Tracks identity of a
556 * connected user is their WordPress.com account, so the connected account's
557 * email domain is the identity signal.
558 *
559 * @return bool
560 */
561 private static function is_current_user_automattician() {
562 if ( function_exists( 'is_automattician' ) ) {
563 return (bool) is_automattician( get_current_user_id() );
564 }
565
566 $user_data = ( new Connection_Manager() )->get_connected_user_data();
567 $email = is_array( $user_data ) && ! empty( $user_data['email'] )
568 ? strtolower( (string) $user_data['email'] )
569 : '';
570
571 return '' !== $email && '@automattic.com' === substr( $email, -15 );
572 }
573
574 /**
575 * Whether My Jetpack is loaded on this host.
576 *
577 * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
578 * VIP removes it from outside this codebase, where that filter cannot answer.
579 *
580 * @return bool
581 */
582 private static function has_my_jetpack() {
583 if ( ( new Host() )->is_vip_site() ) {
584 return false;
585 }
586
587 return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
588 && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
589 && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
590 }
591
592 /**
593 * Name of the purchase granting this site AI ("Jetpack Complete"), from
594 * My Jetpack's purchase data — its Plans section's source.
595 *
596 * @return array{name: string} An empty string when nothing paid grants AI
597 * or the data is unavailable.
598 */
599 private static function get_ai_plan_info() {
600 $empty = array( 'name' => '' );
601
602 if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
603 return $empty;
604 }
605
606 // The purchase lookup can make remote requests; cache the outcome
607 // (empty included) so the admin page pays that cost at most hourly.
608 $cached = get_transient( 'jetpack_ai_overview_plan_info' );
609 if ( is_array( $cached ) ) {
610 return array_merge( $empty, $cached );
611 }
612
613 // A failed lookup is not "no purchase": skip the hour-long cache so the
614 // next page load can try again instead of pinning a blank name.
615 if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
616 return $empty;
617 }
618
619 $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
620
621 // A WordPress.com site names its own plan, never a Jetpack one.
622 if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
623 $purchase = self::get_wpcom_plan_purchase();
624 }
625
626 $info = $empty;
627 if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
628 // The design shows the bare plan name ("Complete", "Business"), so
629 // trim the store names' brand prefixes; they are untranslated.
630 $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
631 }
632
633 set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
634
635 return $info;
636 }
637
638 /**
639 * Whether a purchase was bought from the Jetpack store.
640 *
641 * @param object|null $purchase Purchase from My Jetpack.
642 * @return bool
643 */
644 private static function is_jetpack_purchase( $purchase ) {
645 return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
646 }
647
648 /**
649 * The purchase behind the site's current WordPress.com plan.
650 *
651 * The plan record carries only a slug and a display name lives on purchases,
652 * so the slug is matched back to the purchase that created it.
653 *
654 * @return object|null Null when the plan or its purchase cannot be found.
655 */
656 private static function get_wpcom_plan_purchase() {
657 $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
658 $plan_slug = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
659 ? (string) $current_plan['product_slug']
660 : '';
661
662 // An empty slug simply matches nothing below, so it needs no guard.
663 foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
664 if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
665 return $purchase;
666 }
667 }
668
669 return null;
670 }
671
672 /**
673 * Override the base render() to skip wrap_ui entirely.
674 *
675 * Wrap_ui renders the Jetpack masthead header and static footer, which
676 * duplicate the header/footer that AdminPage (React) already provides.
677 * Calling page_render() directly lets AdminPage own the full layout.
678 */
679 public function render() {
680 $this->page_render();
681 }
682
683 /**
684 * Render the page, or say why it could not be rendered.
685 *
686 * The generated wp-build page owns the markup the app mounts into.
687 */
688 public function page_render() {
689 if ( self::should_render_wp_build() ) {
690 jetpack_plugin_jetpack_ai_hub_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
691 return;
692 }
693
694 // The build output is missing; say so rather than leaving a silent blank page.
695 printf(
696 '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
697 esc_html__( 'Jetpack AI', 'jetpack' ),
698 esc_html__( 'Jetpack AI could not be loaded because its assets are missing. Reinstalling or updating the plugin usually fixes this. If it keeps happening, contact your site administrator or host.', 'jetpack' )
699 );
700 }
701 }
702
703 /*
704 * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
705 * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
706 * add_action() dedupes.
707 */
708 add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );
709