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