Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.37% covered (success)
92.37%
242 / 262
69.23% covered (warning)
69.23%
18 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Agents_Manager
92.37% covered (success)
92.37%
242 / 262
69.23% covered (warning)
69.23%
18 / 26
119.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_ai_icon
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_ai_chat_button
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 get_active_context
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 add_admin_bar_nodes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 enqueue_scripts
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
5
 get_active_variant
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_plugin_information_iframe
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 get_variant
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 is_enabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 passes_admin_checks
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 enqueue_script
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
8
 determine_iso_639_locale
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_assets_json
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
14.55
 calypso_preferences_update
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_instance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_proxied
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 is_tracking_automattician
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 is_dev_mode
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
 register_rest_api
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 is_admin_bar_in_editor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_jetpack_disconnected
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_current_user_data
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_current_site
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Agents manager
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8namespace Automattic\Jetpack\Agents_Manager;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
12use Automattic\Jetpack\Constants;
13
14/**
15 * Class Agents_Manager
16 */
17class Agents_Manager {
18    /**
19     * The package version of the Agents Manager package.
20     *
21     * @var string
22     */
23    const PACKAGE_VERSION = '0.12.4';
24
25    /**
26     * Class instance.
27     *
28     * @var Agents_Manager
29     */
30    private static $instance = null;
31
32    /**
33     * Agents_Manager constructor.
34     */
35    private function __construct() {
36        add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
37        add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
38
39        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
40        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
41
42        add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_nodes' ), 100 );
43
44        Sidebar_Open_Preservation::init();
45    }
46
47    /**
48     * Get the SVG icon markup for the AI chat button.
49     *
50     * @return string The SVG markup.
51     */
52    private function get_ai_icon() {
53        // Not cached: the sparkle icon's aria-label is translated, and the locale can switch mid-request.
54        return '<svg class="ab-icon" role="img" aria-label="' . esc_attr__( 'Agent', 'jetpack-agents-manager' ) . '" width="24" height="24" viewBox="-45 -45 490 490" xmlns="http://www.w3.org/2000/svg">
55                                <path fill="currentColor" d="M391.528 188.061L309.455 159.75C276.997 148.597 251.403 123.003 240.25 90.5451L211.939 8.47185C208.079 -2.82395 191.921 -2.82395 188.061 8.47185L159.75 90.5451C148.597 123.003 123.003 148.597 90.5451 159.75L8.47185 188.061C-2.82395 191.921 -2.82395 208.079 8.47185 211.939L90.5451 240.25C123.003 251.403 148.597 276.997 159.75 309.455L188.061 391.528C191.921 402.824 208.079 402.824 211.939 391.528L240.25 309.455C251.403 276.997 276.997 251.403 309.455 240.25L391.528 211.939C402.824 208.079 402.824 191.921 391.528 188.061ZM295.728 206.077L254.692 220.232C238.391 225.809 225.666 238.677 220.089 254.835L205.934 295.871C203.932 301.591 195.925 301.591 193.923 295.871L179.768 254.835C174.191 238.534 161.323 225.809 145.165 220.232L104.129 206.077C98.4093 204.075 98.4093 196.068 104.129 194.066L145.165 179.911C161.466 174.334 174.191 161.466 179.768 145.308L193.923 104.272C195.925 98.5523 203.932 98.5523 205.934 104.272L220.089 145.308C225.666 161.609 238.534 174.334 254.692 179.911L295.728 194.066C301.448 196.068 301.448 204.075 295.728 206.077Z" />
56                            </svg>';
57    }
58
59    /**
60     * Add the standalone AI chat button to the admin bar.
61     *
62     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
63     */
64    public function add_ai_chat_button( $wp_admin_bar ) {
65        $meta = array(
66            'menu_title' => __( 'Agent', 'jetpack-agents-manager' ),
67            'icon'       => 'sparkle',
68            // The wp-admin bundle mounts the chat into this div.
69            'html'       => '<div id="agents-manager-masterbar"></div>',
70        );
71
72        // The "Agent" label shows while the chat is hidden (closed or minimized).
73        // Pre-hide it when the chat will restore visible; the bundle keeps the
74        // class in step afterwards.
75        $state = Open_State_Store::get_cached();
76        if ( ! empty( $state['agents_manager_open'] ) && empty( $state['agents_manager_minimized'] ) ) {
77            $meta['class'] = 'is-chat-visible';
78        }
79
80        $wp_admin_bar->add_menu(
81            array(
82                'id'     => 'agents-manager-ai-chat',
83                'parent' => 'top-secondary',
84                'title'  => '<span title="' . esc_attr__( 'Agent', 'jetpack-agents-manager' ) . '">' . $this->get_ai_icon() . '</span>'
85                    . '<span class="agents-manager-ai-chat-label" aria-hidden="true"><span>' . esc_html__( 'Agent', 'jetpack-agents-manager' ) . '</span></span>',
86                'meta'   => $meta,
87            )
88        );
89    }
90
91    /**
92     * The Agents Manager context for this request, or null when it should not load.
93     *
94     * Shared by `add_admin_bar_nodes()` and `enqueue_scripts()`.
95     *
96     * @return array{variant: string, disconnected: bool, gutenberg: bool, enabled: bool}|null
97     */
98    private function get_active_context() {
99        // P2 frontends get neither the admin bar entry points nor the app.
100        $stylesheet = get_stylesheet();
101        $is_p2      = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
102
103        if ( ! is_admin() && $is_p2 ) {
104            return null;
105        }
106
107        // Determine which variant to load (null = don't load).
108        $variant = self::get_active_variant();
109        if ( null === $variant ) {
110            return null;
111        }
112
113        return array(
114            'variant'      => $variant,
115            'disconnected' => str_contains( $variant, 'disconnected' ),
116            'gutenberg'    => $this->is_block_editor(),
117            'enabled'      => self::is_enabled(),
118        );
119    }
120
121    /**
122     * Add the Agents Manager entry points to the admin bar.
123     *
124     * Hooked unconditionally, with eligibility resolved here rather than at registration, so the
125     * admin-bar REST endpoints â€” which fire `admin_bar_menu` with no enqueue hook â€” get the same
126     * nodes as a page load.
127     *
128     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
129     */
130    public function add_admin_bar_nodes( $wp_admin_bar ) {
131        $context = $this->get_active_context();
132        if ( null === $context ) {
133            return;
134        }
135
136        // Gutenberg uses JS when no admin bar is visible and the server-side branch below when one is available.
137        if ( ! $context['gutenberg'] ) {
138            // Standalone AI chat button, shown whenever the full Agents Manager app is enabled.
139            if ( ! $context['disconnected'] && $context['enabled'] ) {
140                $this->add_ai_chat_button( $wp_admin_bar );
141            }
142        }
143
144        // When the block editor exposes the WordPress admin bar, add the entry point there.
145        if ( ! $context['disconnected'] && self::is_admin_bar_in_editor() ) {
146            if ( $context['enabled'] ) {
147                $this->add_ai_chat_button( $wp_admin_bar );
148            }
149        }
150    }
151
152    /**
153     * Enqueue Agents Manager scripts and add inline script data.
154     */
155    public function enqueue_scripts() {
156        $context = $this->get_active_context();
157        if ( null === $context ) {
158            return;
159        }
160
161        $variant = $context['variant'];
162
163        /**
164         * Filter to register agent provider modules for the Agents Manager.
165         *
166         * Plugins can hook into this filter to register script module IDs that export
167         * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
168         * import these modules and merge their providers.
169         *
170         * @param array $providers Array of provider script module IDs.
171         */
172        $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
173
174        /**
175         * Filter the default agent ID for the Agents Manager.
176         *
177         * Allows host applications (e.g., WooCommerce AI) to specify a custom
178         * workflow agent instead of the default orchestrator. The value is passed to
179         * the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
180         *
181         * @param string|null $agent_id The agent ID to use, or null for default behavior.
182         */
183        $agent_id = apply_filters( 'agents_manager_agent_id', null );
184
185        $script_version = $this->enqueue_script( $variant );
186
187        $inline_data = array(
188            'agentProviders'  => $agent_providers,
189            'isDevMode'       => self::is_dev_mode(),
190            'isA11n'          => self::is_tracking_automattician(),
191            'isWpcomPlatform' => ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_platform(),
192            'sectionName'     => apply_filters( 'agents_manager_section_name', $variant ),
193            'currentUser'     => $this->get_current_user_data(),
194            'site'            => $this->get_current_site(),
195        );
196
197        if ( null !== $script_version ) {
198            $inline_data['version'] = $variant . ':' . $script_version;
199        }
200
201        if ( $agent_id ) {
202            $inline_data['agentId'] = $agent_id;
203        }
204
205        /**
206         * Filter the data exposed to the Agents Manager frontend.
207         *
208         * @param array $inline_data Data encoded into `agentsManagerData`.
209         */
210        $filtered    = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
211        $inline_data = is_array( $filtered ) ? $filtered : $inline_data;
212
213        wp_add_inline_script(
214            'agents-manager',
215            'const agentsManagerData = ' . wp_json_encode(
216                $inline_data,
217                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
218            ) . ';',
219            'before'
220        );
221    }
222
223    /**
224     * The script variant active for this request, or null if none.
225     *
226     * Single source of truth for "is the Agents Manager app loaded on this
227     * request?". Used both to enqueue the app and to gate the server-side
228     * sidebar pre-render, so the pre-rendered shell can never appear on a page
229     * where the app won't mount to reconcile it.
230     *
231     * @return string|null The variant name, or null if scripts should not be loaded.
232     */
233    public static function get_active_variant() {
234        if ( self::is_plugin_information_iframe() ) {
235            return null;
236        }
237
238        /**
239         * Filter the script variant the Agents Manager loads for this request.
240         *
241         * @since 0.1.0
242         *
243         * @param string|null $variant The resolved variant, or null to not load.
244         */
245        return apply_filters( 'agents_manager_variant', self::get_variant() );
246    }
247
248    /**
249     * Whether the current request renders the plugin information iframe.
250     *
251     * The parent plugin screen may load Agents Manager, but the iframe must not
252     * bootstrap a second copy of the app.
253     *
254     * @return bool
255     */
256    private static function is_plugin_information_iframe() {
257        global $current_screen;
258
259        if ( ! $current_screen || 'plugin-install' !== $current_screen->id ) {
260            return false;
261        }
262
263        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a request context check, not a form submission.
264        return isset( $_GET['tab'] ) && 'plugin-information' === sanitize_text_field( wp_unslash( $_GET['tab'] ) );
265    }
266
267    /**
268     * Determine which script variant to load, or null if none should be loaded.
269     *
270     * Combines the gating logic (should we load at all?) with variant selection
271     * (which build to use?) into a single method so the two cannot get out of sync.
272     *
273     * @return string|null The variant name, or null if scripts should not be loaded.
274     */
275    private static function get_variant() {
276        // Agents Manager does not render on the frontend.
277        if ( ! is_admin() ) {
278            return null;
279        }
280
281        // Apply wp-admin exclusions (customizer, asset, and preview contexts).
282        if ( ! self::passes_admin_checks() ) {
283            return null;
284        }
285
286        if ( ! self::is_enabled() ) {
287            return null;
288        }
289
290        $disconnected = self::is_jetpack_disconnected();
291
292        if ( self::is_block_editor() ) {
293            return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
294        }
295
296        return $disconnected ? null : 'wp-admin';
297    }
298
299    /**
300     * Returns true if the Agents Manager should be loaded in the current context.
301     *
302     * @return bool
303     */
304    public static function is_enabled() {
305        $enabled = false;
306
307        if ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
308            // Block editor only. Hooked by hosts such as jetpack-mu-wpcom and the Jetpack AI Sidebar.
309            $enabled = true;
310        }
311
312        /**
313         * Filters whether an integration requests the Agents Manager shell on this request.
314         *
315         * Providers should preserve an existing true value so multiple integrations can
316         * request the shared shell independently.
317         *
318         * @since 0.9.1
319         *
320         * @param bool $should_load Whether another integration already requested the shell.
321         */
322        $should_load = (bool) apply_filters( 'agents_manager_should_load', false );
323
324        return $enabled || $should_load;
325    }
326
327    /**
328     * Returns true if the current wp-admin context passes all exclusion checks.
329     *
330     * Excludes customizer previews, Gutenberg asset requests, and preview query
331     * param contexts.
332     *
333     * @return bool
334     */
335    private static function passes_admin_checks() {
336        // Don't load in customizer preview iframe.
337        if ( is_customize_preview() ) {
338            return false;
339        }
340
341        // Don't load during Gutenberg asset requests or preview contexts.
342        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
343        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
344        $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
345        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
346        $is_preview_overlay = isset( $_GET['preview_overlay'] );
347        if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
348            return false;
349        }
350
351        return true;
352    }
353
354    /**
355     * Enqueue Agents Manager script based on context.
356     *
357     * @param string $variant The variant of the asset file to get.
358     * @return string|null The deployed build version from the asset file, or null when unavailable.
359     */
360    private function enqueue_script( $variant ) {
361        $cache_key  = 'agents-manager-asset-' . $variant . '.asset.json';
362        $asset_file = get_transient( $cache_key );
363
364        if ( ! $asset_file ) {
365            $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
366            if ( ! $asset_file ) {
367                return null;
368            }
369            set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
370        }
371
372        // When the request is dev mode, use a random cache buster as the version for easier debugging.
373        $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
374
375        $script_dependencies = $asset_file['dependencies'] ?? array();
376
377        // Load translations for connected variants from widgets.wp.com.
378        // Disconnected variants have no translatable UI, so skip them (as Help
379        // Center does). English needs no translation file.
380        if ( ! str_contains( $variant, 'disconnected' ) ) {
381            $locale = self::determine_iso_639_locale();
382
383            if ( 'en' !== $locale ) {
384                wp_enqueue_script(
385                    'agents-manager-translations',
386                    'https://widgets.wp.com/agents-manager/languages/' . $locale . '-v1.js',
387                    array( 'wp-i18n' ),
388                    $version,
389                    true
390                );
391
392                $script_dependencies[] = 'agents-manager-translations';
393            }
394        }
395
396        wp_enqueue_script(
397            'agents-manager',
398            'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
399            $script_dependencies,
400            $version,
401            /**
402             * Filter the strategy to use when enqueuing the script.
403             *
404             * @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
405             * @param string $handle The handle of the script.
406             */
407            apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
408        );
409
410        if ( 'gutenberg-disconnected' !== $variant ) {
411            wp_enqueue_style(
412                'agents-manager-style',
413                'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
414                array(),
415                $version
416            );
417        }
418
419        return (string) $asset_file['version'];
420    }
421
422    /**
423     * Returns the ISO 639 conforming locale string for the current user.
424     *
425     * Normalizes the WordPress user locale to match the widgets.wp.com translation
426     * file naming at languages/{code}-v1.js. Preserves the region for the few locales
427     * where it is meaningful (pt-br, zh-tw, zh-cn); strips the region for all others;
428     * falls back to 'en' when the locale is empty.
429     *
430     * @return string The ISO 639 locale string, e.g. "en".
431     */
432    private static function determine_iso_639_locale() {
433        $language = get_user_locale();
434        $language = strtolower( $language );
435
436        if ( in_array( $language, array( 'pt_br', 'pt-br', 'zh_tw', 'zh-tw', 'zh_cn', 'zh-cn' ), true ) ) {
437            $language = str_replace( '_', '-', $language );
438        } else {
439            $language = preg_replace( '/([-_].*)$/i', '', $language );
440        }
441
442        if ( empty( $language ) ) {
443            return 'en';
444        }
445
446        return $language;
447    }
448
449    /**
450     * Get the asset via file-system on wpcom and via network on Atomic sites.
451     *
452     * @param string $filepath The URL to download the asset file from.
453     * @return array|null The asset file data or null on failure.
454     */
455    private static function get_assets_json( $filepath ) {
456        $accessible_directly = file_exists( ABSPATH . $filepath );
457
458        if ( $accessible_directly ) {
459            $file_contents = file_get_contents( ABSPATH . $filepath );
460
461            if ( false === $file_contents ) {
462                return null;
463            }
464
465            return json_decode( $file_contents, true );
466        }
467
468        $request = wp_remote_get( 'https://' . $filepath );
469
470        if ( is_wp_error( $request ) ) {
471            return null;
472        }
473
474        $response_code = wp_remote_retrieve_response_code( $request );
475        if ( 200 !== $response_code ) {
476            return null;
477        }
478
479        $content_type = wp_remote_retrieve_header( $request, 'content-type' );
480        if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
481            return null;
482        }
483
484        $body = wp_remote_retrieve_body( $request );
485        if ( '' === $body ) {
486            return null;
487        }
488
489        $decoded = json_decode( $body, true );
490        if ( json_last_error() !== JSON_ERROR_NONE ) {
491            return null;
492        }
493
494        return $decoded;
495    }
496
497    /**
498     * Update the calypso preferences.
499     *
500     * @param \stdClass $preferences The preferences.
501     *
502     * @return \stdClass The preferences.
503     */
504    public function calypso_preferences_update( $preferences ) {
505        // Check if agents_manager_router_history exists and is a valid array structure
506        if ( ! isset( $preferences->agents_manager_router_history ) ||
507            ! is_array( $preferences->agents_manager_router_history ) ) {
508            return $preferences;
509        }
510
511        $router_history = $preferences->agents_manager_router_history;
512
513        // Check if entries exist and is an array
514        if ( ! isset( $router_history['entries'] ) ||
515            ! is_array( $router_history['entries'] ) ) {
516            return $preferences;
517        }
518
519        $entries = $router_history['entries'];
520
521        // Limit entries to 50 to prevent spamming entries in the router history.
522        if ( count( $entries ) > 50 ) {
523            // Keep only the last 49 entries and add the root entry at the beginning.
524            $entries = array_slice( $entries, -49 );
525            // Keep the start at root so the back button always works.
526            array_unshift(
527                $entries,
528                array(
529                    'pathname' => '/',
530                    'search'   => '',
531                    'hash'     => '',
532                    'key'      => 'default',
533                    'state'    => null,
534                )
535            );
536
537            // Update the preferences object directly
538            $preferences->agents_manager_router_history['entries'] = $entries;
539            $preferences->agents_manager_router_history['index']   = 49;
540        }
541
542        return $preferences;
543    }
544
545    /**
546     * Creates instance.
547     *
548     * @return Agents_Manager
549     */
550    public static function init() {
551        if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
552            return self::get_instance();
553        }
554
555        self::$instance = new self();
556
557        /**
558         * Fires once the Agents Manager class has been instantiated.
559         *
560         * @since 0.5.0
561         */
562        do_action( 'jetpack_agents_manager_initialized' );
563
564        return self::$instance;
565    }
566
567    /**
568     * Returns the instance of the Agents Manager class.
569     *
570     * @return Agents_Manager
571     */
572    public static function get_instance() {
573        return self::$instance;
574    }
575
576    /**
577     * Returns whether the current request is coming from the A8C proxy.
578     *
579     * @return bool
580     */
581    private static function is_proxied() {
582        // On Simple sites, use the wpcom function if available.
583        if ( function_exists( 'wpcom_is_proxied_request' ) ) {
584            return wpcom_is_proxied_request();
585        }
586
587        // On WoA/Garden sites, check server variable or constant.
588        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
589            ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
590            : Constants::is_true( 'A8C_PROXIED_REQUEST' );
591    }
592
593    /**
594     * Returns whether the current visitor should be marked as an Automattician in tracking.
595     *
596     * @return bool
597     */
598    private static function is_tracking_automattician() {
599        $is_automattician = function_exists( 'is_automattician' ) && (bool) is_automattician();
600
601        return $is_automattician || self::is_proxied() || Constants::is_true( 'AT_PROXIED_REQUEST' );
602    }
603
604    /**
605     * Enables "Development" features that should be accessible only for admins.
606     */
607    private static function is_dev_mode() {
608        // Known local environments.
609        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
610        if (
611            $domain === 'localhost' ||
612            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
613            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
614        ) {
615            return true;
616        }
617
618        // A8C development.
619        if ( self::is_proxied() ) {
620            return true;
621        }
622
623        if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
624            switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
625                case 1:
626                case 2:
627                case 3: // Pressable
628                case 32:
629                case 118: // Commerce garden client.
630                    return true;
631            }
632        }
633
634        return false;
635    }
636
637    /**
638     * Register the Agents Manager endpoints.
639     */
640    public function register_rest_api() {
641        ( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
642        ( new REST_Jetpack_AI_JWT() )->register_rest_route();
643    }
644
645    /**
646     * Returns true if the current screen is the block editor.
647     *
648     * @return bool True if the current screen is the block editor.
649     */
650    private static function is_block_editor() {
651        if ( ! function_exists( 'get_current_screen' ) ) {
652            return false;
653        }
654
655        $current_screen = get_current_screen();
656        // The widgets screen has the block editor but no Gutenberg top bar.
657        return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
658    }
659
660    /**
661     * Returns true when the WordPress admin bar is available in the block editor.
662     *
663     * The frontend uses the visible admin bar as its signal to move editor entry points out of the
664     * Gutenberg toolbar. Use WordPress's matching server-side signal so a classic editor admin bar
665     * receives those entry points too, not only Gutenberg's experimental omnibar.
666     *
667     * @return bool
668     */
669    private static function is_admin_bar_in_editor() {
670        return self::is_block_editor() && is_admin_bar_showing();
671    }
672
673    /**
674     * Returns true if the current user is NOT connected through Jetpack.
675     *
676     * Mirrors the logic from Help_Center::is_jetpack_disconnected().
677     *
678     * @return bool True if the site uses Jetpack but the current user is not connected.
679     */
680    private static function is_jetpack_disconnected() {
681        $user_id = get_current_user_id();
682        $blog_id = get_current_blog_id();
683
684        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
685            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
686        }
687
688        if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
689            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
690        }
691
692        return false;
693    }
694
695    /**
696     * Get current user data for the agents manager.
697     *
698     * Mirrors the user data structure from Help Center's helpCenterData.
699     *
700     * @return array|null User data array or null if not logged in.
701     */
702    private function get_current_user_data() {
703        $user_id = get_current_user_id();
704        if ( ! $user_id ) {
705            return null;
706        }
707
708        $user_data = get_userdata( $user_id );
709        if ( ! $user_data ) {
710            return null;
711        }
712
713        $user_email = $user_data->user_email;
714
715        // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
716        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
717            $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
718        } else {
719            $avatar_url = get_avatar_url( $user_id );
720        }
721
722        return array(
723            'ID'           => $user_id,
724            'username'     => $user_data->user_login,
725            'display_name' => $user_data->display_name,
726            'avatar_URL'   => $avatar_url,
727            'email'        => $user_email,
728        );
729    }
730
731    /**
732     * Get current site data for the agents manager.
733     *
734     * Returns minimal site data needed by AgentsManager (ID and domain only).
735     * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
736     *
737     * @return array Site data with ID and domain.
738     */
739    private function get_current_site() {
740        /*
741         * Atomic sites have the WP.com blog ID stored as a Jetpack option.
742         * This code deliberately doesn't use `Jetpack_Options::get_option`
743         * so it works even when Jetpack has not been loaded.
744         */
745        $jetpack_options = get_option( 'jetpack_options' );
746        if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
747            $site_id = (int) $jetpack_options['id'];
748        } else {
749            $site_id = get_current_blog_id();
750        }
751
752        return array(
753            'ID'     => $site_id,
754            'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
755        );
756    }
757}