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