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