Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.93% covered (success)
93.93%
201 / 214
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Page
95.26% covered (success)
95.26%
201 / 211
64.71% covered (warning)
64.71%
11 / 17
64
0.00% covered (danger)
0.00%
0 / 1
 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%
7 / 7
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
96.12% covered (success)
96.12%
99 / 103
0.00% covered (danger)
0.00%
0 / 1
18
 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_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
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
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\Admin_UI\Admin_Menu;
12use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16use Automattic\Jetpack\Redirect;
17use Automattic\Jetpack\Status;
18use Automattic\Jetpack\Status\Host;
19use Automattic\Jetpack\Terms_Of_Service;
20use Automattic\Jetpack\Tracking;
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
27
28/**
29 * Builds the Jetpack AI admin page and its sidebar menu entry.
30 */
31class Jetpack_AI_Page {
32
33    /**
34     * Register the page and its page-specific hooks.
35     *
36     * The AI Hub owns its full React layout, so it does not need the legacy
37     * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
38     * lets WordPress.com Simple load the same page without replacing its
39     * request-wide Jetpack_Admin_Page compatibility stub.
40     */
41    public function add_actions() {
42        $is_offline_mode = ( new Status() )->is_offline_mode();
43
44        if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
45            return;
46        }
47
48        if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
49            return;
50        }
51
52        $hook = $this->get_page_hook();
53        if ( ! $hook ) {
54            return;
55        }
56
57        add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
58
59        // Preserve the standalone Jetpack page's existing base stylesheet. Simple
60        // never loaded it for the Hub because it conflicts with wpcom admin pages.
61        if ( ! ( new Host() )->is_wpcom_simple() ) {
62            add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
63        }
64
65        $this->add_page_actions( $hook );
66    }
67
68    /**
69     * Register the "AI" submenu under the Jetpack top-level menu.
70     *
71     * @return string|false Hook returned by Admin_Menu::add_menu().
72     */
73    public function get_page_hook() {
74        return Admin_Menu::add_menu(
75            // "Jetpack AI" is a product name and should not be translated.
76            'Jetpack AI',
77            'Jetpack AI',
78            'manage_options',
79            'jetpack-ai',
80            array( $this, 'render' )
81        );
82    }
83
84    /**
85     * Attach page-specific actions.
86     *
87     * @param string $hook The page hook returned by get_page_hook().
88     */
89    public function add_page_actions( $hook ) {
90        add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
91    }
92
93    /**
94     * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
95     */
96    public function admin_styles() {
97        $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
98
99        wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
100        wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
101        wp_style_add_data( 'jetpack-admin', 'suffix', $min );
102    }
103
104    /**
105     * Request the existing Agents Manager shell for this page.
106     */
107    public function load_agents_manager() {
108        if ( ! self::is_scheduled_tasks_enabled() ) {
109            return;
110        }
111
112        Agents_Manager::init();
113
114        add_filter( 'agents_manager_should_load', '__return_true' );
115        add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
116        add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
117        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
118    }
119
120    /**
121     * Use the generic WP Orchestrator agent in AI Hub.
122     *
123     * @return string Agent ID.
124     */
125    public function get_agents_manager_agent_id() {
126        return 'wp-orchestrator';
127    }
128
129    /**
130     * Add the AI Hub provider that supplies scheduled task starter prompts.
131     *
132     * @param array $providers Existing provider module URLs.
133     * @return array Updated provider module URLs.
134     */
135    public function add_scheduled_tasks_provider( $providers ) {
136        $providers[] = add_query_arg(
137            'ver',
138            JETPACK__VERSION,
139            plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
140        );
141
142        return $providers;
143    }
144
145    /**
146     * Customize Agents Manager's empty view for the Scheduled tasks page.
147     *
148     * @param array $data Existing Agents Manager data.
149     * @return array Updated Agents Manager data.
150     */
151    public function add_scheduled_tasks_data( $data ) {
152        $current_user = wp_get_current_user();
153
154        $data['emptyViewHeading'] = sprintf(
155            /* translators: %s: Current user's display name. */
156            __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
157            $current_user->display_name
158        );
159        $data['emptyViewHelp']                     = __( 'Got a different request? Ask away.', 'jetpack' );
160        $data['scheduledTaskEmptyViewSuggestions'] = array(
161            array(
162                'id'         => 'create-daily-reminder',
163                'label'      => __( 'Create a daily reminder', 'jetpack' ),
164                'prompt'     => __( 'Create a daily reminder', 'jetpack' ),
165                'autoSubmit' => true,
166            ),
167            array(
168                'id'         => 'draft-weekly-post',
169                'label'      => __( 'Draft a weekly post', 'jetpack' ),
170                'prompt'     => __( 'Draft a weekly post', 'jetpack' ),
171                'autoSubmit' => true,
172            ),
173            array(
174                'id'         => 'schedule-monthly-report',
175                'label'      => __( 'Schedule a monthly report', 'jetpack' ),
176                'prompt'     => __( 'Schedule a monthly report', 'jetpack' ),
177                'autoSubmit' => true,
178            ),
179        );
180
181        return $data;
182    }
183
184    /**
185     * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
186     *
187     * @since 16.2
188     *
189     * @return bool
190     */
191    private static function is_scheduled_tasks_enabled() {
192        return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
193    }
194
195    /**
196     * Enqueue scripts and styles for the AI admin page.
197     */
198    public function page_admin_scripts() {
199        $script_path    = JETPACK__PLUGIN_DIR . '_inc/build/jetpack-ai-admin.asset.php';
200        $script_deps    = array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-polyfill' );
201        $script_version = JETPACK__VERSION;
202
203        if ( file_exists( $script_path ) ) {
204            $asset_manifest = include $script_path;
205            $script_deps    = $asset_manifest['dependencies'];
206            $script_version = $asset_manifest['version'];
207        }
208
209        $blog_id     = Connection_Manager::get_site_id( true );
210        $status      = new Status();
211        $site_suffix = $status->get_site_suffix();
212        // Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
213        // include '::' for subdirectory installs, which would break the URL. This matches
214        // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
215        $site_host         = wp_parse_url( home_url(), PHP_URL_HOST );
216        $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
217        // On Atomic link to WPCOM activity log; on self-hosted link to the local wp-admin page.
218        $activity_log_url = ( new Host() )->is_woa_site()
219            ? 'https://wordpress.com/activity-log/' . $activity_log_site
220            : admin_url( 'admin.php?page=jetpack-activity-log' );
221
222        /*
223         * Link SEO settings to the dedicated Jetpack SEO page where it exists,
224         * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
225         * filter is required in addition to the cohort check: is_seo_surface_visible()
226         * alone returns true on all of wpcom-platform even while the flag is off —
227         * it answers only the cohort half, and page registration requires both
228         * (see packages/seo Initializer::init()).
229         */
230        $seo_settings_url          = admin_url( 'admin.php?page=jetpack#/traffic' );
231        $is_internal_test          = jetpack_is_internal_testing_environment();
232        $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
233        if (
234            // The exact-symbol guard matters: the autoloader can select an older
235            // jetpack-seo copy from another plugin that has the class but not
236            // this method, and class_exists alone would then fatal here.
237            method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
238            && (bool) apply_filters( 'rsm_jetpack_seo', false )
239            && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
240        ) {
241            $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
242        }
243
244        wp_enqueue_script(
245            'jetpack-ai-admin',
246            plugins_url( '_inc/build/jetpack-ai-admin.js', JETPACK__PLUGIN_FILE ),
247            $script_deps,
248            $script_version,
249            true
250        );
251
252        wp_set_script_translations( 'jetpack-ai-admin', 'jetpack' );
253
254        // The Tracks sender (w.js); without it, queued events never leave the
255        // browser. Consent-gated like the other surfaces that load it.
256        $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
257        if ( $can_send_tracks ) {
258            Tracking::register_tracks_functions_scripts( true );
259        }
260
261        if ( $show_scheduled_tasks_view ) {
262            Connection_Initial_State::render_script( 'jetpack-ai-admin' );
263            // Webpack reads this to load the lazy Scheduled tasks chunk; see _inc/client/ai/public-path.js.
264            wp_add_inline_script(
265                'jetpack-ai-admin',
266                'window.Jetpack_AI_Admin_Assets_Base_Url = ' . wp_json_encode( plugins_url( '_inc/build/', JETPACK__PLUGIN_FILE ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
267                'before'
268            );
269        }
270
271        $host = new Host();
272
273        /**
274         * Filters the host-specific AI Hub configuration.
275         *
276         * @since 16.2
277         *
278         * @param array $config AI Hub host configuration.
279         */
280        $config = apply_filters(
281            'jetpack_ai_admin_config',
282            array(
283                // The Overview and Features views launch on non-VIP self-hosted sites first.
284                // Keep this filterable so hosts can close them independently.
285                'showGatedViews'    => ! $host->is_vip_site()
286                    && ( ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ) ),
287                'showA12sBadge'     => $host->is_woa_site() && $is_internal_test,
288                'isUserConnected'   => ( new Connection_Manager() )->is_user_connected(),
289                // My Jetpack is removed on VIP, so that route dead-ends there.
290                'userConnectionUrl' => $host->is_vip_site()
291                    ? 'admin.php?page=jetpack#/connect-user'
292                    : 'admin.php?page=my-jetpack#/connection',
293                'mcpSettingsApi'    => array(
294                    'path'   => '/wpcom/v2/jetpack-ai/mcp-settings',
295                    'format' => 'jetpack',
296                ),
297            )
298        );
299
300        $show_gated_views = ! empty( $config['showGatedViews'] );
301
302        $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
303
304        $settings = array(
305            'blogId'            => $blog_id ? (int) $blog_id : 0,
306            'activityLogUrl'    => $activity_log_url,
307            'seoSettingsUrl'    => $seo_settings_url,
308            'siteAdminUrl'      => admin_url(),
309            'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ),
310            'apiRoot'           => esc_url_raw( rest_url() ),
311            'apiNonce'          => wp_create_nonce( 'wp_rest' ),
312            'pluginUrl'         => plugins_url( '', JETPACK__PLUGIN_FILE ),
313            // The redirect entry bakes in the jetpack_ai_yearly product and
314            // a post-checkout return to this page, so both can be
315            // retargeted without shipping a code change.
316            'upgradeUrl'        => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
317            // The purchase granting AI — the usage card only uses it to pick
318            // the right loading-skeleton shape before the usage fetch lands.
319            // Only looked up when a gated view can render the card.
320            'planName'          => $plan_info['name'],
321            'showFeaturesView'  => $show_gated_views,
322            'showA12sBadge'     => ! empty( $config['showA12sBadge'] ),
323            // The tab and its Agents Manager sidebar ship disabled by default.
324            'featureFlags'      => array(
325                Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
326            ),
327            // The usage endpoint proxies as the current user, which needs
328            // their own WordPress.com account linked — not just the site.
329            'isUserConnected'   => ! empty( $config['isUserConnected'] ),
330            // Tracks audience properties for the jetpack_mcp_* events, per the
331            // Tracks standards for AI product events (AIINT-586). The client
332            // sends them as the strings 'true'/'false' (AIINT-576).
333            'isA11n'            => self::is_current_user_automattician(),
334            'isTest'            => $is_internal_test,
335            // Identity for Tracks; the lookup can call WordPress.com on a
336            // cache miss, so it shares the sender's guard.
337            'tracksUserData'    => $can_send_tracks ? self::get_tracks_user_data() : null,
338            'mcpSettingsApi'    => $config['mcpSettingsApi'],
339        );
340
341        wp_add_inline_script(
342            'jetpack-ai-admin',
343            'var jetpackAiSettings = ' . wp_json_encode(
344                $settings,
345                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
346            ) . ';',
347            'before'
348        );
349
350        /*
351         * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
352         * event-fire time and attaches it to every Tracks event fired from this page.
353         * Without it, JS-fired events from self-hosted sites carry no blog_id — the
354         * Tracks pixel cannot resolve the site — so the events cannot be joined to
355         * plan or site data. Mirrors Connection\Initial_State::render().
356         */
357        wp_add_inline_script(
358            'jetpack-ai-admin',
359            sprintf(
360                'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
361                absint( $blog_id )
362            ),
363            'before'
364        );
365
366        wp_enqueue_style(
367            'jetpack-ai-admin',
368            plugins_url( '_inc/build/jetpack-ai-admin.css', JETPACK__PLUGIN_FILE ),
369            array( 'wp-components' ),
370            $script_version
371        );
372    }
373
374    /**
375     * Connected-user identity for Tracks; null when no WordPress.com account is
376     * linked. Two keys only, so email and locale stay out of the page HTML.
377     *
378     * @return array{userid:int, username:string}|null
379     */
380    private static function get_tracks_user_data() {
381        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
382        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
383            return null;
384        }
385
386        return array(
387            'userid'   => (int) $identity['userid'],
388            'username' => (string) $identity['username'],
389        );
390    }
391
392    /**
393     * Whether the current user is an Automattician.
394     *
395     * Identity check for the Tracks `is_a11n` audience property — it answers
396     * "who is this", not "may they use the tool", so it deliberately does not
397     * consult the MCP allowlist: allowlisted external testers are not a11ns.
398     *
399     * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
400     * Self-hosted Jetpack has no platform check; there the Tracks identity of a
401     * connected user is their WordPress.com account, so the connected account's
402     * email domain is the identity signal.
403     *
404     * @return bool
405     */
406    private static function is_current_user_automattician() {
407        if ( function_exists( 'is_automattician' ) ) {
408            return (bool) is_automattician( get_current_user_id() );
409        }
410
411        $user_data = ( new Connection_Manager() )->get_connected_user_data();
412        $email     = is_array( $user_data ) && ! empty( $user_data['email'] )
413            ? strtolower( (string) $user_data['email'] )
414            : '';
415
416        return '' !== $email && '@automattic.com' === substr( $email, -15 );
417    }
418
419    /**
420     * Name of the purchase granting this site AI ("Jetpack Complete"), from
421     * My Jetpack's purchase data — its Plans section's source.
422     *
423     * @return array{name: string} An empty string when nothing paid grants AI
424     *                             or the data is unavailable.
425     */
426    private static function get_ai_plan_info() {
427        $empty = array( 'name' => '' );
428
429        if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
430            return $empty;
431        }
432
433        // The purchase lookup can make remote requests; cache the outcome
434        // (empty included) so the admin page pays that cost at most hourly.
435        $cached = get_transient( 'jetpack_ai_overview_plan_info' );
436        if ( is_array( $cached ) ) {
437            return array_merge( $empty, $cached );
438        }
439
440        // A failed lookup is not "no purchase": skip the hour-long cache so the
441        // next page load can try again instead of pinning a blank name.
442        if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
443            return $empty;
444        }
445
446        $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
447
448        // A WordPress.com site names its own plan, never a Jetpack one.
449        if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
450            $purchase = self::get_wpcom_plan_purchase();
451        }
452
453        $info = $empty;
454        if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
455            // The design shows the bare plan name ("Complete", "Business"), so
456            // trim the store names' brand prefixes; they are untranslated.
457            $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
458        }
459
460        set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
461
462        return $info;
463    }
464
465    /**
466     * Whether a purchase was bought from the Jetpack store.
467     *
468     * @param object|null $purchase Purchase from My Jetpack.
469     * @return bool
470     */
471    private static function is_jetpack_purchase( $purchase ) {
472        return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
473    }
474
475    /**
476     * The purchase behind the site's current WordPress.com plan.
477     *
478     * The plan record carries only a slug and a display name lives on purchases,
479     * so the slug is matched back to the purchase that created it.
480     *
481     * @return object|null Null when the plan or its purchase cannot be found.
482     */
483    private static function get_wpcom_plan_purchase() {
484        $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
485        $plan_slug    = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
486            ? (string) $current_plan['product_slug']
487            : '';
488
489        // An empty slug simply matches nothing below, so it needs no guard.
490        foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
491            if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
492                return $purchase;
493            }
494        }
495
496        return null;
497    }
498
499    /**
500     * Override the base render() to skip wrap_ui entirely.
501     *
502     * Wrap_ui renders the Jetpack masthead header and static footer, which
503     * duplicate the header/footer that AdminPage (React) already provides.
504     * Calling page_render() directly lets AdminPage own the full layout.
505     */
506    public function render() {
507        $this->page_render();
508    }
509
510    /**
511     * Render the page container. The React app mounts into this div.
512     *
513     * AdminPage from @automattic/jetpack-components handles the full-page layout.
514     */
515    public function page_render() {
516        ?>
517        <div id="jetpack-ai-root"></div>
518        <?php
519    }
520}