Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.96% covered (warning)
66.96%
231 / 345
37.50% covered (danger)
37.50%
9 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Agents_Manager
67.15% covered (warning)
67.15%
231 / 344
37.50% covered (danger)
37.50%
9 / 24
659.25
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
 should_display_menu_panel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_icon
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 add_menu_panel
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_scripts
68.97% covered (warning)
68.97%
40 / 58
0.00% covered (danger)
0.00%
0 / 1
12.99
 get_variant
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
12.04
 is_enabled
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 passes_admin_checks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 enqueue_script
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_proxied
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 is_dev_mode
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
13
 register_rest_api
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 should_use_unified_experience
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 has_unified_chat_opt_in_enabled
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 fetch_unified_experience_preference
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
8.10
 is_loading_on_frontend
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
10.37
 is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 is_ciab_environment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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-mu-wpcom
6 */
7
8namespace A8C\FSE;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11
12/**
13 * Class Agents_Manager
14 */
15class Agents_Manager {
16    /**
17     * Help Center URL for disconnected variants.
18     *
19     * @var string
20     */
21    private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
22
23    /**
24     * Class instance.
25     *
26     * @var Agents_Manager
27     */
28    private static $instance = null;
29
30    /**
31     * Agents_Manager constructor.
32     */
33    public function __construct() {
34        add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
35        add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
36
37        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
38        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
39        add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
40        add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
41    }
42
43    /**
44     * Check if the agents manager menu panel should be displayed.
45     *
46     * @return bool True if the menu panel should be displayed.
47     */
48    public function should_display_menu_panel() {
49        return apply_filters( 'agents_manager_use_unified_experience', false );
50    }
51
52    /**
53     * Get the SVG icon markup for a given icon name.
54     *
55     * @param string $icon_name The name of the icon to retrieve.
56     * @return string The SVG markup.
57     */
58    private function get_icon( $icon_name ) {
59        $icons = array(
60            'comment' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H6c-1.1 0-2 .9-2 2v12.9c0 .6.5 1.1 1.1 1.1.3 0 .5-.1.8-.3L8.5 17H18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 11c0 .3-.2.5-.5.5H7.9l-2.4 2.4V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v9z" /></svg>',
61            'backup'  => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.5 12h1.75l-2.5 3-2.5-3H4a8 8 0 113.134 6.35l.907-1.194A6.5 6.5 0 105.5 12zm9.53 1.97l-2.28-2.28V8.5a.75.75 0 00-1.5 0V12a.747.747 0 00.218.529l1.282-.84-1.28.842 2.5 2.5a.75.75 0 101.06-1.061z" /></svg>',
62            'page'    => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" /><path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /></svg>',
63            'video'   => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.7 3H5.3C4 3 3 4 3 5.3v13.4C3 20 4 21 5.3 21h13.4c1.3 0 2.3-1 2.3-2.3V5.3C21 4 20 3 18.7 3zm.8 15.7c0 .4-.4.8-.8.8H5.3c-.4 0-.8-.4-.8-.8V5.3c0-.4.4-.8.8-.8h13.4c.4 0 .8.4.8.8v13.4zM10 15l5-3-5-3v6z" /></svg>',
64            'rss'     => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 10.2h-.8v1.5H5c1.9 0 3.8.8 5.1 2.1 1.4 1.4 2.1 3.2 2.1 5.1v.8h1.5V19c0-2.3-.9-4.5-2.6-6.2-1.6-1.6-3.8-2.6-6.1-2.6zm10.4-1.6C12.6 5.8 8.9 4.2 5 4.2h-.8v1.5H5c3.5 0 6.9 1.4 9.4 3.9s3.9 5.8 3.9 9.4v.8h1.5V19c0-3.9-1.6-7.6-4.4-10.4zM4 20h3v-3H4v3z" /></svg>',
65        );
66
67        return $icons[ $icon_name ] ?? '';
68    }
69
70    /**
71     * Add the agents manager menu panel to the admin bar.
72     *
73     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
74     */
75    public function add_menu_panel( $wp_admin_bar ) {
76        // Add chat support group
77        $wp_admin_bar->add_group(
78            array(
79                'parent' => 'agents-manager',
80                'id'     => 'agents-manager-menu-panel-chat',
81                'meta'   => array(
82                    'class' => 'ab-sub-secondary',
83                ),
84            )
85        );
86
87        // Add chat support menu item
88        $wp_admin_bar->add_node(
89            array(
90                'parent' => 'agents-manager-menu-panel-chat',
91                'id'     => 'agents-manager-chat-support',
92                'title'  => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-mu-wpcom' ) . '</span>',
93            )
94        );
95
96        // Add chat history menu item
97        $wp_admin_bar->add_node(
98            array(
99                'parent' => 'agents-manager-menu-panel-chat',
100                'id'     => 'agents-manager-chat-history',
101                'title'  => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-mu-wpcom' ) . '</span>',
102            )
103        );
104
105        // Add links group
106        $wp_admin_bar->add_group(
107            array(
108                'parent' => 'agents-manager',
109                'id'     => 'agents-manager-menu-panel-links',
110                'meta'   => array(
111                    'class' => 'ab-sub-secondary',
112                ),
113            )
114        );
115
116        // Add support guides menu item
117        $wp_admin_bar->add_node(
118            array(
119                'parent' => 'agents-manager-menu-panel-links',
120                'id'     => 'agents-manager-support-guides',
121                'title'  => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-mu-wpcom' ) . '</span>',
122            )
123        );
124
125        // Add courses menu item
126        $wp_admin_bar->add_node(
127            array(
128                'parent' => 'agents-manager-menu-panel-links',
129                'id'     => 'agents-manager-courses',
130                'title'  => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-mu-wpcom' ) . '</span>',
131                'href'   => 'https://wordpress.com/support/courses/',
132                'meta'   => array(
133                    'target' => '_blank',
134                ),
135            )
136        );
137
138        // Add product updates menu item
139        $wp_admin_bar->add_node(
140            array(
141                'parent' => 'agents-manager-menu-panel-links',
142                'id'     => 'agents-manager-product-updates',
143                'title'  => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-mu-wpcom' ) . '</span>',
144                'href'   => 'https://wordpress.com/blog/category/product-features/',
145                'meta'   => array(
146                    'target' => '_blank',
147                ),
148            )
149        );
150    }
151
152    /**
153     * Enqueue Agents Manager scripts and add inline script data.
154     */
155    public function enqueue_scripts() {
156        // Early return for P2 frontend - don't add admin bar or enqueue scripts.
157        $stylesheet = get_stylesheet();
158        $is_p2      = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
159
160        if ( ! is_admin() && $is_p2 ) {
161            return;
162        }
163
164        // Determine which variant to load (null = don't load).
165        $variant = $this->get_variant();
166        if ( null === $variant ) {
167            return;
168        }
169        $use_disconnected = str_contains( $variant, 'disconnected' );
170        $is_gutenberg     = $this->is_block_editor();
171
172        // In Gutenberg, dequeue Help Center so we don't end up with two buttons.
173        // Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
174        if ( $is_gutenberg ) {
175            wp_dequeue_script( 'help-center' );
176            wp_dequeue_style( 'help-center-style' );
177        }
178
179        // For non-Gutenberg environments, add to admin bar
180        // Gutenberg doesn't have an admin bar, so JS will handle UI insertion
181        if ( ! $is_gutenberg ) {
182            add_action(
183                'admin_bar_menu',
184                function ( $wp_admin_bar ) use ( $use_disconnected ) {
185                    // Remove the help-center menu item
186                    $wp_admin_bar->remove_node( 'help-center' );
187
188                    $menu_args = array(
189                        'id'     => 'agents-manager',
190                        'title'  => '<span title="' . __( 'Help Center', 'jetpack-mu-wpcom' ) . '"><svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
191                                        <path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 16v-2h2v2h-2zm2-3v-1.141A3.991 3.991 0 0016 10a4 4 0 00-8 0h2c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2a1 1 0 00-1 1v2h2z" />
192                                    </svg></span>',
193                        'parent' => 'top-secondary',
194                    );
195
196                    // For disconnected variants, link directly to help center instead of showing dropdown
197                    if ( $use_disconnected ) {
198                        $menu_args['href'] = self::HELP_CENTER_URL;
199                        $menu_args['meta'] = array(
200                            'target' => '_blank',
201                        );
202                    } else {
203                        // For full variants, show the dropdown menu panel
204                        $menu_args['meta'] = array(
205                            'html'   => '<div id="agents-manager-masterbar" />',
206                            'class'  => 'menupop',
207                            'target' => '_blank',
208                        );
209                    }
210
211                    // Add the main agents manager menu node
212                    $wp_admin_bar->add_menu( $menu_args );
213                },
214                // Add the agents manager icon to the admin bar after the help center is added, so we can remove it.
215                100
216            );
217
218            // Initialize the agents manager menu panel (only for full variants, not disconnected)
219            if ( ! $use_disconnected ) {
220                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
221            }
222        }
223
224        /**
225         * Filter to register agent provider modules for the Agents Manager.
226         *
227         * Plugins can hook into this filter to register script module IDs that export
228         * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
229         * import these modules and merge their providers.
230         *
231         * @param array $providers Array of provider script module IDs.
232         */
233        $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
234
235        /**
236         * Filter to determine if user should see the unified chat experience.
237         *
238         * When true, Help Center will render UnifiedAIAgent instead of traditional UI.
239         * The filter is hooked by should_use_unified_experience() in this class.
240         *
241         * @param bool $use_unified_experience Whether to use unified experience. Default false.
242         */
243        $use_unified_experience = apply_filters( 'agents_manager_use_unified_experience', false );
244
245        $this->enqueue_script( $variant );
246
247        wp_add_inline_script(
248            'agents-manager',
249            'const agentsManagerData = ' . wp_json_encode(
250                array(
251                    'agentProviders'       => $agent_providers,
252                    'useUnifiedExperience' => $use_unified_experience,
253                    'isDevMode'            => self::is_dev_mode(),
254                    'sectionName'          => $variant,
255                    'currentUser'          => $this->get_current_user_data(),
256                    'site'                 => $this->get_current_site(),
257                    'helpCenterUrl'        => self::HELP_CENTER_URL,
258                ),
259                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
260            ) . ';',
261            'before'
262        );
263    }
264
265    /**
266     * Determine which script variant to load, or null if none should be loaded.
267     *
268     * Combines the gating logic (should we load at all?) with variant selection
269     * (which build to use?) into a single method so the two cannot get out of sync.
270     *
271     * @return string|null The variant name, or null if scripts should not be loaded.
272     */
273    private function get_variant() {
274        // CIAB/Next Admin: only load when disconnected (connected CIAB is handled by Help Center).
275        if ( $this->is_ciab_environment() ) {
276            if ( $this->is_enabled() && $this->is_jetpack_disconnected() ) {
277                return 'ciab-disconnected';
278            }
279            return null;
280        }
281
282        // Frontend: load disconnected variant for eligible logged-in editors.
283        if ( ! is_admin() ) {
284            if ( $this->is_loading_on_frontend() && $this->is_enabled() ) {
285                return 'wp-admin-disconnected';
286            }
287            return null;
288        }
289
290        // Apply wp-admin exclusions (WooCommerce, customizer, preview contexts).
291        if ( ! $this->passes_admin_checks() ) {
292            return null;
293        }
294
295        if ( ! $this->is_enabled() ) {
296            return null;
297        }
298
299        $disconnected = $this->is_jetpack_disconnected();
300
301        if ( $this->is_block_editor() ) {
302            return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
303        }
304
305        return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
306    }
307
308    /**
309     * Returns true if the Agents Manager should be loaded in the current context.
310     *
311     * @return bool
312     */
313    private function is_enabled() {
314        // Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
315        if ( apply_filters( 'agents_manager_use_unified_experience', false ) ) {
316            return true;
317        }
318
319        // Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
320        if ( $this->is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
321            return true;
322        }
323
324        return false;
325    }
326
327    /**
328     * Returns true if the current wp-admin context passes all exclusion checks.
329     *
330     * Excludes WooCommerce Admin home, customizer preview, Gutenberg asset requests,
331     * and preview query param contexts.
332     *
333     * @return bool
334     */
335    private function passes_admin_checks() {
336        // Don't load on WooCommerce Admin home page to avoid UI conflicts.
337        global $current_screen;
338        if ( $current_screen && $current_screen->id === 'woocommerce_page_wc-admin' ) {
339            return false;
340        }
341
342        // Don't load in customizer preview iframe.
343        if ( is_customize_preview() ) {
344            return false;
345        }
346
347        // Don't load during Gutenberg asset requests or preview contexts.
348        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
349        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
350        $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
351        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
352        $is_preview_overlay = isset( $_GET['preview_overlay'] );
353        if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
354            return false;
355        }
356
357        return true;
358    }
359
360    /**
361     * Enqueue Agents Manager script based on context.
362     *
363     * @param string $variant The variant of the asset file to get.
364     */
365    private function enqueue_script( $variant ) {
366        $cache_key  = 'agents-manager-asset-' . $variant . '.asset.json';
367        $asset_file = get_transient( $cache_key );
368
369        if ( ! $asset_file ) {
370            $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
371            if ( ! $asset_file ) {
372                return;
373            }
374            set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
375        }
376
377        // When the request is dev mode, use a random cache buster as the version for easier debugging.
378        $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
379
380        $script_dependencies = $asset_file['dependencies'] ?? array();
381
382        wp_enqueue_script(
383            'agents-manager',
384            'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
385            $script_dependencies,
386            $version,
387            true
388        );
389
390        if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
391            wp_enqueue_style(
392                'agents-manager-style',
393                'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
394                array(),
395                $version
396            );
397        }
398    }
399
400    /**
401     * Get the asset via file-system on wpcom and via network on Atomic sites.
402     *
403     * @param string $filepath The URL to download the asset file from.
404     * @return array|null The asset file data or null on failure.
405     */
406    private static function get_assets_json( $filepath ) {
407        $accessible_directly = file_exists( ABSPATH . $filepath );
408
409        if ( $accessible_directly ) {
410            $file_contents = file_get_contents( ABSPATH . $filepath );
411
412            if ( false === $file_contents ) {
413                return null;
414            }
415
416            return json_decode( $file_contents, true );
417        }
418
419        $request = wp_remote_get( 'https://' . $filepath );
420
421        if ( is_wp_error( $request ) ) {
422            return null;
423        }
424
425        $response_code = wp_remote_retrieve_response_code( $request );
426        if ( 200 !== $response_code ) {
427            return null;
428        }
429
430        $content_type = wp_remote_retrieve_header( $request, 'content-type' );
431        if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
432            return null;
433        }
434
435        $body = wp_remote_retrieve_body( $request );
436        if ( '' === $body ) {
437            return null;
438        }
439
440        $decoded = json_decode( $body, true );
441        if ( json_last_error() !== JSON_ERROR_NONE ) {
442            return null;
443        }
444
445        return $decoded;
446    }
447
448    /**
449     * Update the calypso preferences.
450     *
451     * @param \stdClass $preferences The preferences.
452     *
453     * @return \stdClass The preferences.
454     */
455    public function calypso_preferences_update( $preferences ) {
456        // Check if agents_manager_router_history exists and is a valid array structure
457        if ( ! isset( $preferences->agents_manager_router_history ) ||
458            ! is_array( $preferences->agents_manager_router_history ) ) {
459            return $preferences;
460        }
461
462        $router_history = $preferences->agents_manager_router_history;
463
464        // Check if entries exist and is an array
465        if ( ! isset( $router_history['entries'] ) ||
466            ! is_array( $router_history['entries'] ) ) {
467            return $preferences;
468        }
469
470        $entries = $router_history['entries'];
471
472        // Limit entries to 50 to prevent spamming entries in the router history.
473        if ( count( $entries ) > 50 ) {
474            // Keep only the last 49 entries and add the root entry at the beginning.
475            $entries = array_slice( $entries, -49 );
476            // Keep the start at root so the back button always works.
477            array_unshift(
478                $entries,
479                array(
480                    'pathname' => '/',
481                    'search'   => '',
482                    'hash'     => '',
483                    'key'      => 'default',
484                    'state'    => null,
485                )
486            );
487
488            // Update the preferences object directly
489            $preferences->agents_manager_router_history['entries'] = $entries;
490            $preferences->agents_manager_router_history['index']   = 49;
491        }
492
493        return $preferences;
494    }
495
496    /**
497     * Creates instance.
498     *
499     * @return void
500     */
501    public static function init() {
502        if ( self::$instance === null ) {
503            self::$instance = new self();
504        }
505    }
506
507    /**
508     * Returns whether the current request is coming from the A8C proxy.
509     *
510     * @return bool
511     */
512    private static function is_proxied() {
513        // On Simple sites, use the wpcom function if available.
514        if ( function_exists( 'wpcom_is_proxied_request' ) ) {
515            return wpcom_is_proxied_request();
516        }
517
518        // On WoA/Garden sites, check server variable or constant.
519        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
520            ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
521            : defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST;
522    }
523
524    /**
525     * Enables "Development" features that should be accessible only for admins.
526     */
527    private static function is_dev_mode() {
528        // Known local environments.
529        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
530        if (
531            $domain === 'localhost' ||
532            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
533            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
534        ) {
535            return true;
536        }
537
538        // A8C development.
539        if ( self::is_proxied() ) {
540            return true;
541        }
542
543        if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST && defined( 'ATOMIC_CLIENT_ID' ) ) {
544            switch ( ATOMIC_CLIENT_ID ) {
545                case 1:
546                case 2:
547                case 3: // Pressable
548                case 32:
549                case 118: // Commerce garden client (ciab)
550                    return true;
551            }
552        }
553
554        return false;
555    }
556
557    /**
558     * Register the Agents Manager endpoints.
559     */
560    public function register_rest_api() {
561        require_once __DIR__ . '/class-wp-rest-agents-manager-persisted-open-state.php';
562        $controller = new WP_REST_Agents_Manager_Persisted_Open_State();
563        $controller->register_rest_route();
564    }
565
566    /**
567     * Determine if user should see unified experience.
568     *
569     * @return bool
570     */
571    public function should_use_unified_experience() {
572        // Early return for non-proxied/dev mode requests.
573        // This feature is currently only available to Automattic employees testing via proxy.
574        if ( ! self::is_dev_mode() ) {
575            return false;
576        }
577
578        $user_id = get_current_user_id();
579
580        if ( ! $user_id ) {
581            return false;
582        }
583
584        $is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
585        if ( $is_simple_site ) {
586            // On Simple sites, evaluate locally.
587            // Check Automattician and opt-in setting.
588            $is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
589            if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
590                return true;
591            }
592        }
593
594        // On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
595        // This avoids duplicating rollout logic and handles cases where
596        // wpcom-specific functions (like get_user_attribute) aren't available.
597        if ( $this->fetch_unified_experience_preference() ) {
598            return true;
599        }
600
601        // False, for now.
602        // In the future: users with a big sky site (similar to https://github.a8c.com/Automattic/wpcom/pull/196449/files), a big-sky free trial or a paid plan.
603        return false;
604    }
605
606    /**
607     * Check if user has enabled unified chat opt-in in their Automattician options.
608     *
609     * This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
610     * Only used on Simple sites where get_user_attribute is available.
611     *
612     * @param int $user_id User ID.
613     *
614     * @return bool
615     */
616    private function has_unified_chat_opt_in_enabled( $user_id ) {
617        if ( ! function_exists( '\get_user_attribute' ) ) {
618            return false;
619        }
620
621        $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
622        return ! empty( $calypso_prefs['unified_ai_chat'] );
623    }
624
625    /**
626     * Fetch unified experience preference from wpcom via Jetpack Connection.
627     *
628     * Used on Atomic sites to delegate the decision to wpcom, which has
629     * access to user attributes and can evaluate the rollout logic.
630     *
631     * Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
632     *
633     * @return bool Whether user should see unified experience.
634     */
635    private function fetch_unified_experience_preference() {
636        $user_id = get_current_user_id();
637        if ( ! $user_id ) {
638            return false;
639        }
640
641        // Check transient cache first (per-user cache).
642        $cache_key     = 'unified-experience-' . $user_id;
643        $cached_result = get_transient( $cache_key );
644        if ( false !== $cached_result ) {
645            return (bool) $cached_result;
646        }
647
648        // Check if user is connected before making API call.
649        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
650            return false;
651        }
652
653        // Call dedicated agents-manager/state endpoint.
654        $wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
655            '/agents-manager/state?key=unified_ai_chat',
656            '2',
657            array( 'method' => 'GET' )
658        );
659
660        if ( is_wp_error( $wpcom_request ) ) {
661            // Cache failures too to avoid hammering the API.
662            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
663            return false;
664        }
665
666        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
667        if ( 200 !== $response_code ) {
668            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
669            return false;
670        }
671
672        $body         = wp_remote_retrieve_body( $wpcom_request );
673        $decoded_body = json_decode( $body, true );
674
675        // The response is { "unified_ai_chat": true/false } when using key param.
676        $result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
677
678        // Cache for 1 minute.
679        set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
680
681        return $result;
682    }
683
684    /**
685     * Returns true if the current request is on the frontend and the user can edit posts.
686     *
687     * Mirrors Help_Center::is_loading_on_frontend().
688     *
689     * @return bool True if loading on the frontend for an eligible user.
690     */
691    private function is_loading_on_frontend() {
692        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
693        if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
694            return false;
695        }
696
697        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
698        if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
699            return false;
700        }
701
702        $can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
703        return ! is_admin() && ! $this->is_block_editor() && $can_edit_posts;
704    }
705
706    /**
707     * Returns true if the current screen is the block editor.
708     *
709     * @return bool True if the current screen is the block editor.
710     */
711    private function is_block_editor() {
712        if ( ! function_exists( 'get_current_screen' ) ) {
713            return false;
714        }
715
716        $current_screen = get_current_screen();
717        // The widgets screen has the block editor but no Gutenberg top bar.
718        return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
719    }
720
721    /**
722     * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
723     *
724     * Uses the same detection method as Help Center: checks if next_admin_init has fired.
725     *
726     * @return bool True if CIAB/Next Admin environment.
727     */
728    private function is_ciab_environment() {
729        return (bool) did_action( 'next_admin_init' );
730    }
731
732    /**
733     * Returns true if the current user is NOT connected through Jetpack.
734     *
735     * Mirrors the logic from Help_Center::is_jetpack_disconnected().
736     *
737     * @return bool True if the site uses Jetpack but the current user is not connected.
738     */
739    private function is_jetpack_disconnected() {
740        $user_id = get_current_user_id();
741        $blog_id = get_current_blog_id();
742
743        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
744            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
745        }
746
747        if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
748            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
749        }
750
751        return false;
752    }
753
754    /**
755     * Get current user data for the agents manager.
756     *
757     * Mirrors the user data structure from Help Center's helpCenterData.
758     *
759     * @return array|null User data array or null if not logged in.
760     */
761    private function get_current_user_data() {
762        $user_id = get_current_user_id();
763        if ( ! $user_id ) {
764            return null;
765        }
766
767        $user_data = get_userdata( $user_id );
768        if ( ! $user_data ) {
769            return null;
770        }
771
772        $user_email = $user_data->user_email;
773
774        // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
775        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
776            $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
777        } else {
778            $avatar_url = get_avatar_url( $user_id );
779        }
780
781        return array(
782            'ID'           => $user_id,
783            'username'     => $user_data->user_login,
784            'display_name' => $user_data->display_name,
785            'avatar_URL'   => $avatar_url,
786            'email'        => $user_email,
787        );
788    }
789
790    /**
791     * Get current site data for the agents manager.
792     *
793     * Returns minimal site data needed by AgentsManager (ID and domain only).
794     * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
795     *
796     * @return array Site data with ID and domain.
797     */
798    private function get_current_site() {
799        /*
800         * Atomic sites have the WP.com blog ID stored as a Jetpack option.
801         * This code deliberately doesn't use `Jetpack_Options::get_option`
802         * so it works even when Jetpack has not been loaded.
803         */
804        $jetpack_options = get_option( 'jetpack_options' );
805        if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
806            $site_id = (int) $jetpack_options['id'];
807        } else {
808            $site_id = get_current_blog_id();
809        }
810
811        return array(
812            'ID'     => $site_id,
813            'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
814        );
815    }
816}
817
818add_action( 'init', array( __NAMESPACE__ . '\Agents_Manager', 'init' ) );