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