Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.93% covered (success)
96.93%
253 / 261
72.73% covered (warning)
72.73%
16 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Page
97.25% covered (success)
97.25%
248 / 255
72.73% covered (warning)
72.73%
16 / 22
83
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
100.00% covered (success)
100.00%
120 / 120
100.00% covered (success)
100.00%
1 / 1
21
 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        // Link to the Jetpack SEO page when it registers, which takes both checks below
281        // (see the SEO package's Initializer::init()); otherwise to the Traffic card.
282        $seo_settings_url          = admin_url( 'admin.php?page=jetpack-settings#/traffic' );
283        $is_internal_test          = jetpack_is_internal_testing_environment();
284        $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
285        if (
286            // is_available() is the newer of the two; an older SEO package loaded first can lack it.
287            method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_available' )
288            && \Automattic\Jetpack\SEO\Initializer::is_available()
289            && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
290        ) {
291            $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
292        }
293
294        // The route bundle is registered by wp-build; this handle exists only to carry the
295        // inline settings below, which the app reads from `window.jetpackAiSettings`.
296        wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
297        wp_enqueue_script( 'jetpack-ai-admin' );
298
299        // The Tracks sender (w.js); without it, queued events never leave the
300        // browser. Consent-gated like the other surfaces that load it.
301        $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
302        if ( $can_send_tracks ) {
303            Tracking::register_tracks_functions_scripts( true );
304        }
305
306        // Unconditional, as on the other Jetpack admin pages: the connection store
307        // reads it, and only Scheduled tasks used to need it here.
308        Connection_Initial_State::render_script( 'jetpack-ai-admin' );
309
310        $host            = new Host();
311        $has_my_jetpack  = self::has_my_jetpack();
312        $is_offline_mode = $status->is_offline_mode();
313
314        /**
315         * Filters the host-specific AI Hub configuration.
316         *
317         * @since 16.2
318         *
319         * @param array $config AI Hub host configuration.
320         */
321        $config = apply_filters(
322            'jetpack_ai_admin_config',
323            array(
324                // The Overview and Features views launch on self-hosted sites first.
325                // Keep this filterable so hosts can close them independently.
326                'showGatedViews'    => ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ),
327                'showA12sBadge'     => $host->is_woa_site() && $is_internal_test,
328                // The same verdicts the feature-settings endpoint reports. That call
329                // exists for the AI Features toggles; the notice must not wait on it.
330                'isUserConnected'   => Jetpack_AI_Settings::user_is_connected(),
331                'isConnected'       => Jetpack_AI_Settings::site_is_connected(),
332                'hostAllowsAi'      => Jetpack_AI_Settings::host_allows_ai(),
333                'masterEnabled'     => Jetpack_AI_Settings::is_master_enabled(),
334                // The route, not a flag: each one documents a different hook.
335                'masterForcedOff'   => Jetpack_AI_Settings::get_master_forced_off_route(),
336                'isOfflineMode'     => $is_offline_mode,
337                'canConnectSite'    => current_user_can( 'jetpack_connect' ),
338                // These three answer one question; a filter changing one alone leaves
339                // a label pointing at a page that is not there.
340                'hasMyJetpack'      => $has_my_jetpack,
341                'userConnectionUrl' => $has_my_jetpack
342                    ? 'admin.php?page=my-jetpack#/connection'
343                    : 'admin.php?page=jetpack-settings#/connect-user',
344                'manageUrl'         => $has_my_jetpack
345                    ? 'admin.php?page=my-jetpack#/features'
346                    : 'admin.php?page=jetpack_modules',
347                'mcpSettingsApi'    => array(
348                    'path'   => '/wpcom/v2/jetpack-ai/mcp-settings',
349                    'format' => 'jetpack',
350                ),
351                // Self-hosted sites get the Hub's `-self-hosted` ChatGPT and Claude connect links.
352                'isSelfHosted'      => ! $host->is_wpcom_platform(),
353            )
354        );
355
356        // WordPress.com sites use Calypso's log; self-hosted sites use the wp-admin page, which only
357        // exists while the `activity-log` module is on. An empty URL hides the row.
358        $activity_log_filtered = false;
359        if ( $host->is_wpcom_platform() ) {
360            $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
361            // Answered by the host, without a remote call.
362            $activity_log_filtered = Current_Plan::supports( 'full-activity-log' );
363        } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
364            $activity_log_url = admin_url( 'admin.php?page=' . Jetpack_Activity_Log::PAGE_SLUG );
365            // Filters need paid access. That check can call WordPress.com, so skip it
366            // when the row can't show: offline, or without a linked user.
367            $activity_log_filtered = ! $is_offline_mode
368                && ! empty( $config['isUserConnected'] )
369                && Activity_Log_REST_Controller::has_activity_logs_access();
370        } else {
371            $activity_log_url = '';
372        }
373        if ( $activity_log_filtered ) {
374            // add_query_arg() does not encode values.
375            $activity_log_url = add_query_arg( 'actor', rawurlencode( self::ALL_AI_AGENTS_ACTOR_ID ), $activity_log_url );
376        }
377
378        $show_gated_views = ! empty( $config['showGatedViews'] );
379
380        $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
381
382        $settings = array(
383            'blogId'              => $blog_id ? (int) $blog_id : 0,
384            'activityLogUrl'      => $activity_log_url,
385            'activityLogFiltered' => $activity_log_filtered,
386            'seoSettingsUrl'      => $seo_settings_url,
387            'searchSettingsUrl'   => self::get_search_settings_url(),
388            'siteAdminUrl'        => admin_url(),
389            'userConnectionUrl'   => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
390            'manageUrl'           => esc_url_raw( $config['manageUrl'] ?? '' ),
391            'hasMyJetpack'        => ! empty( $config['hasMyJetpack'] ),
392            'isConnected'         => ! empty( $config['isConnected'] ),
393            'hostAllowsAi'        => ! empty( $config['hostAllowsAi'] ),
394            'masterEnabled'       => ! empty( $config['masterEnabled'] ),
395            'masterForcedOff'     => in_array(
396                $config['masterForcedOff'] ?? '',
397                array(
398                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
399                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
400                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
401                ),
402                true
403            ) ? $config['masterForcedOff'] : '',
404            'isOfflineMode'       => ! empty( $config['isOfflineMode'] ),
405            'canConnectSite'      => ! empty( $config['canConnectSite'] ),
406            'apiRoot'             => esc_url_raw( rest_url() ),
407            'apiNonce'            => wp_create_nonce( 'wp_rest' ),
408            'pluginUrl'           => plugins_url( '', JETPACK__PLUGIN_FILE ),
409            // Images ship from the plugin directory, so the plugin version is what busts their cache.
410            'assetsVersion'       => JETPACK__VERSION,
411            // The redirect entry bakes in the jetpack_ai_yearly product and
412            // a post-checkout return to this page, so both can be
413            // retargeted without shipping a code change.
414            'upgradeUrl'          => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
415            // The purchase granting AI â€” the usage card only uses it to pick
416            // the right loading-skeleton shape before the usage fetch lands.
417            // Only looked up when a gated view can render the card.
418            'planName'            => $plan_info['name'],
419            'showFeaturesView'    => $show_gated_views,
420            'showA12sBadge'       => ! empty( $config['showA12sBadge'] ),
421            // The tab and its Agents Manager sidebar ship disabled by default.
422            'featureFlags'        => array(
423                Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
424            ),
425            // The usage endpoint proxies as the current user, which needs
426            // their own WordPress.com account linked â€” not just the site.
427            'isUserConnected'     => ! empty( $config['isUserConnected'] ),
428            // Tracks audience properties for the jetpack_mcp_* events, per the
429            // Tracks standards for AI product events (AIINT-586). The client
430            // sends them as the strings 'true'/'false' (AIINT-576).
431            'isA11n'              => self::is_current_user_automattician(),
432            'isTest'              => $is_internal_test,
433            // Identity for Tracks; the lookup can call WordPress.com on a
434            // cache miss, so it shares the sender's guard.
435            'tracksUserData'      => $can_send_tracks ? self::get_tracks_user_data() : null,
436            'mcpSettingsApi'      => $config['mcpSettingsApi'] ?? array(),
437            'isSelfHosted'        => true === ( $config['isSelfHosted'] ?? false ),
438        );
439
440        wp_add_inline_script(
441            'jetpack-ai-admin',
442            'var jetpackAiSettings = ' . wp_json_encode(
443                $settings,
444                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
445            ) . ';',
446            'before'
447        );
448
449        /*
450         * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
451         * event-fire time and attaches it to every Tracks event fired from this page.
452         * Without it, JS-fired events from self-hosted sites carry no blog_id â€” the
453         * Tracks pixel cannot resolve the site â€” so the events cannot be joined to
454         * plan or site data. Mirrors Connection\Initial_State::render().
455         */
456        wp_add_inline_script(
457            'jetpack-ai-admin',
458            sprintf(
459                'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
460                absint( $blog_id )
461            ),
462            'before'
463        );
464    }
465
466    /**
467     * Connected-user identity for Tracks; null when no WordPress.com account is
468     * linked. Two keys only, so email and locale stay out of the page HTML.
469     *
470     * @return array{userid:int, username:string}|null
471     */
472    private static function get_tracks_user_data() {
473        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
474        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
475            return null;
476        }
477
478        return array(
479            'userid'   => (int) $identity['userid'],
480            'username' => (string) $identity['username'],
481        );
482    }
483
484    /**
485     * Whether the current user is an Automattician.
486     *
487     * Identity check for the Tracks `is_a11n` audience property â€” it answers
488     * "who is this", not "may they use the tool", so it deliberately does not
489     * consult the MCP allowlist: allowlisted external testers are not a11ns.
490     *
491     * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
492     * Self-hosted Jetpack has no platform check; there the Tracks identity of a
493     * connected user is their WordPress.com account, so the connected account's
494     * email domain is the identity signal.
495     *
496     * @return bool
497     */
498    private static function is_current_user_automattician() {
499        if ( function_exists( 'is_automattician' ) ) {
500            return (bool) is_automattician( get_current_user_id() );
501        }
502
503        $user_data = ( new Connection_Manager() )->get_connected_user_data();
504        $email     = is_array( $user_data ) && ! empty( $user_data['email'] )
505            ? strtolower( (string) $user_data['email'] )
506            : '';
507
508        return '' !== $email && '@automattic.com' === substr( $email, -15 );
509    }
510
511    /**
512     * The Search dashboard page the AI Answers row links to, or '' once a host
513     * has removed it. Removal is remove_submenu_page() on `admin_menu`, which only
514     * unsets the registry entry â€” menu_page_url() still answers for the page and
515     * wp-admin then denies it â€” so the registry is read after that hook has run.
516     *
517     * @return string
518     */
519    private static function get_search_settings_url() {
520        global $submenu;
521
522        // Search registers under the Jetpack menu, or menu-less (parent '') when
523        // `jetpack_search_should_add_search_submenu` says no; both are reachable.
524        foreach ( array( 'jetpack', '' ) as $parent ) {
525            foreach ( (array) ( $submenu[ $parent ] ?? array() ) as $item ) {
526                if ( isset( $item[2] ) && 'jetpack-search' === $item[2] ) {
527                    return admin_url( 'admin.php?page=jetpack-search#/ai-answers' );
528                }
529            }
530        }
531
532        return '';
533    }
534
535    /**
536     * Whether My Jetpack is loaded on this host.
537     *
538     * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
539     * VIP removes it from outside this codebase, where that filter cannot answer.
540     *
541     * @return bool
542     */
543    private static function has_my_jetpack() {
544        if ( ( new Host() )->is_vip_site() ) {
545            return false;
546        }
547
548        return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
549            && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
550            && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
551    }
552
553    /**
554     * Name of the purchase granting this site AI ("Jetpack Complete"), from
555     * My Jetpack's purchase data â€” its Plans section's source.
556     *
557     * @return array{name: string} An empty string when nothing paid grants AI
558     *                             or the data is unavailable.
559     */
560    private static function get_ai_plan_info() {
561        $empty = array( 'name' => '' );
562
563        if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
564            return $empty;
565        }
566
567        // The purchase lookup can make remote requests; cache the outcome
568        // (empty included) so the admin page pays that cost at most hourly.
569        $cached = get_transient( 'jetpack_ai_overview_plan_info' );
570        if ( is_array( $cached ) ) {
571            return array_merge( $empty, $cached );
572        }
573
574        // A failed lookup is not "no purchase": skip the hour-long cache so the
575        // next page load can try again instead of pinning a blank name.
576        if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
577            return $empty;
578        }
579
580        $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
581
582        // A WordPress.com site names its own plan, never a Jetpack one.
583        if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
584            $purchase = self::get_wpcom_plan_purchase();
585        }
586
587        $info = $empty;
588        if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
589            // The design shows the bare plan name ("Complete", "Business"), so
590            // trim the store names' brand prefixes; they are untranslated.
591            $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
592        }
593
594        set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
595
596        return $info;
597    }
598
599    /**
600     * Whether a purchase was bought from the Jetpack store.
601     *
602     * @param object|null $purchase Purchase from My Jetpack.
603     * @return bool
604     */
605    private static function is_jetpack_purchase( $purchase ) {
606        return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
607    }
608
609    /**
610     * The purchase behind the site's current WordPress.com plan.
611     *
612     * The plan record carries only a slug and a display name lives on purchases,
613     * so the slug is matched back to the purchase that created it.
614     *
615     * @return object|null Null when the plan or its purchase cannot be found.
616     */
617    private static function get_wpcom_plan_purchase() {
618        $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
619        $plan_slug    = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
620            ? (string) $current_plan['product_slug']
621            : '';
622
623        // An empty slug simply matches nothing below, so it needs no guard.
624        foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
625            if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
626                return $purchase;
627            }
628        }
629
630        return null;
631    }
632
633    /**
634     * Override the base render() to skip wrap_ui entirely.
635     *
636     * Wrap_ui renders the Jetpack masthead header and static footer, which
637     * duplicate the header/footer that AdminPage (React) already provides.
638     * Calling page_render() directly lets AdminPage own the full layout.
639     */
640    public function render() {
641        $this->page_render();
642    }
643
644    /**
645     * Render the page, or say why it could not be rendered.
646     *
647     * The generated wp-build page owns the markup the app mounts into.
648     */
649    public function page_render() {
650        if ( self::should_render_wp_build() ) {
651            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.
652            return;
653        }
654
655        // The build output is missing; say so rather than leaving a silent blank page.
656        printf(
657            '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
658            esc_html__( 'Jetpack AI', 'jetpack' ),
659            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' )
660        );
661    }
662}
663
664/*
665 * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
666 * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
667 * add_action() dedupes.
668 */
669add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );