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