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