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