PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-agents-manager / src / class-agents-manager.php

class-agents-manager.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-agents-manager/src/class-agents-manager.php

1,159 lines 39.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Agents manager
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8 namespace Automattic\Jetpack\Agents_Manager;
9
10 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11 use Automattic\Jetpack\Connection\REST_Jetpack_AI_JWT;
12 use Automattic\Jetpack\Constants;
13
14 /**
15 * Class Agents_Manager
16 */
17 class Agents_Manager {
18 /**
19 * The package version of the Agents Manager package.
20 *
21 * @var string
22 */
23 const PACKAGE_VERSION = '0.12.2';
24
25 /**
26 * Help Center URL for disconnected variants.
27 *
28 * @var string
29 */
30 private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
31
32 /**
33 * Class instance.
34 *
35 * @var Agents_Manager
36 */
37 private static $instance = null;
38
39 /**
40 * Agents_Manager constructor.
41 */
42 private function __construct() {
43 add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
44 add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
45
46 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
47 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
48 add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
49
50 // Runs after Help Center's own admin_bar_menu callback, so add_help_menu() can replace its node.
51 add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_nodes' ), 100 );
52
53 add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
54
55 Sidebar_Open_Preservation::init();
56 }
57
58 /**
59 * Check if the agents manager menu panel should be displayed.
60 *
61 * @return bool True if the menu panel should be displayed.
62 */
63 public function should_display_menu_panel() {
64 return self::is_unified_experience();
65 }
66
67 /**
68 * Get the SVG icon markup for a given icon name.
69 *
70 * @param string $icon_name The name of the icon to retrieve.
71 * @return string The SVG markup.
72 */
73 private function get_icon( $icon_name ) {
74 // Not cached: the sparkle icon's aria-label is translated, and the locale can switch mid-request.
75 $icons = array(
76 '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>',
77 '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>',
78 '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>',
79 '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>',
80 '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>',
81 'help' => '<svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
82 <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" />
83 </svg>',
84 'sparkle' => '<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">
85 <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" />
86 </svg>',
87 );
88
89 return $icons[ $icon_name ] ?? '';
90 }
91
92 /**
93 * Add the Agents Manager Help "?" node (`agents-manager`) to the admin bar, replacing the
94 * legacy Help Center node (`help-center`).
95 *
96 * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
97 * @param bool $use_disconnected Disconnected variants link straight to the Help Center instead of opening the dropdown.
98 */
99 public function add_help_menu( $wp_admin_bar, $use_disconnected ) {
100 $wp_admin_bar->remove_node( 'help-center' );
101
102 $menu_args = array(
103 'id' => 'agents-manager',
104 'title' => '<span title="' . esc_attr__( 'Help Center', 'jetpack-agents-manager' ) . '">' . $this->get_icon( 'help' ) . '</span>',
105 'parent' => 'top-secondary',
106 );
107
108 if ( $use_disconnected ) {
109 $menu_args['href'] = self::HELP_CENTER_URL;
110 $menu_args['meta'] = array(
111 'menu_title' => __( 'Help Center', 'jetpack-agents-manager' ),
112 'icon' => 'help',
113 'target' => '_blank',
114 'rel' => 'noopener noreferrer',
115 );
116 } else {
117 $menu_args['meta'] = array(
118 'menu_title' => __( 'Help Center', 'jetpack-agents-manager' ),
119 'icon' => 'help',
120 'class' => 'menupop',
121 );
122 }
123
124 $wp_admin_bar->add_menu( $menu_args );
125 }
126
127 /**
128 * Add the agents manager menu panel to the admin bar.
129 *
130 * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
131 */
132 public function add_menu_panel( $wp_admin_bar ) {
133 // Add chat support group
134 $wp_admin_bar->add_group(
135 array(
136 'parent' => 'agents-manager',
137 'id' => 'agents-manager-menu-panel-chat',
138 'meta' => array(
139 'class' => 'ab-sub-secondary',
140 ),
141 )
142 );
143
144 // Add chat support menu item
145 $wp_admin_bar->add_node(
146 array(
147 'parent' => 'agents-manager-menu-panel-chat',
148 'id' => 'agents-manager-chat-support',
149 'title' => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-agents-manager' ) . '</span>',
150 'meta' => array(
151 'menu_title' => __( 'Chat support', 'jetpack-agents-manager' ),
152 'icon' => 'comment',
153 'route' => '/chat',
154 ),
155 )
156 );
157
158 // Add chat history menu item
159 $wp_admin_bar->add_node(
160 array(
161 'parent' => 'agents-manager-menu-panel-chat',
162 'id' => 'agents-manager-chat-history',
163 'title' => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-agents-manager' ) . '</span>',
164 'meta' => array(
165 'menu_title' => __( 'Chat history', 'jetpack-agents-manager' ),
166 'icon' => 'backup',
167 'route' => '/history',
168 ),
169 )
170 );
171
172 // Add links group
173 $wp_admin_bar->add_group(
174 array(
175 'parent' => 'agents-manager',
176 'id' => 'agents-manager-menu-panel-links',
177 'meta' => array(
178 'class' => 'ab-sub-secondary',
179 ),
180 )
181 );
182
183 // Add support guides menu item
184 $wp_admin_bar->add_node(
185 array(
186 'parent' => 'agents-manager-menu-panel-links',
187 'id' => 'agents-manager-support-guides',
188 'title' => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-agents-manager' ) . '</span>',
189 'meta' => array(
190 'menu_title' => __( 'Support guides', 'jetpack-agents-manager' ),
191 'icon' => 'page',
192 'route' => '/support-guides',
193 ),
194 )
195 );
196
197 // Add courses menu item
198 $wp_admin_bar->add_node(
199 array(
200 'parent' => 'agents-manager-menu-panel-links',
201 'id' => 'agents-manager-courses',
202 'title' => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-agents-manager' ) . '</span>',
203 'href' => 'https://wordpress.com/support/courses/',
204 'meta' => array(
205 'menu_title' => __( 'Courses', 'jetpack-agents-manager' ),
206 'icon' => 'video',
207 'target' => '_blank',
208 'rel' => 'noopener noreferrer',
209 ),
210 )
211 );
212
213 // Add product updates menu item
214 $wp_admin_bar->add_node(
215 array(
216 'parent' => 'agents-manager-menu-panel-links',
217 'id' => 'agents-manager-product-updates',
218 'title' => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-agents-manager' ) . '</span>',
219 'href' => 'https://wordpress.com/blog/category/product-features/',
220 'meta' => array(
221 'menu_title' => __( 'Product updates', 'jetpack-agents-manager' ),
222 'icon' => 'rss',
223 'target' => '_blank',
224 'rel' => 'noopener noreferrer',
225 ),
226 )
227 );
228 }
229
230 /**
231 * Add the standalone AI chat button to the admin bar.
232 *
233 * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
234 */
235 public function add_ai_chat_button( $wp_admin_bar ) {
236 $meta = array(
237 'menu_title' => __( 'Agent', 'jetpack-agents-manager' ),
238 'icon' => 'sparkle',
239 // The wp-admin bundle mounts the chat into this div.
240 'html' => '<div id="agents-manager-masterbar"></div>',
241 );
242
243 // The "Agent" label shows while the chat is hidden (closed or minimized).
244 // Pre-hide it when the chat will restore visible; the bundle keeps the
245 // class in step afterwards.
246 $state = Open_State_Store::get_cached();
247 if ( ! empty( $state['agents_manager_open'] ) && empty( $state['agents_manager_minimized'] ) ) {
248 $meta['class'] = 'is-chat-visible';
249 }
250
251 $wp_admin_bar->add_menu(
252 array(
253 'id' => 'agents-manager-ai-chat',
254 'parent' => 'top-secondary',
255 'title' => '<span title="' . esc_attr__( 'Agent', 'jetpack-agents-manager' ) . '">' . $this->get_icon( 'sparkle' ) . '</span>'
256 . '<span class="agents-manager-ai-chat-label" aria-hidden="true"><span>' . esc_html__( 'Agent', 'jetpack-agents-manager' ) . '</span></span>',
257 'meta' => $meta,
258 )
259 );
260 }
261
262 /**
263 * The Agents Manager context for this request, or null when it should not load.
264 *
265 * Shared by `add_admin_bar_nodes()` and `enqueue_scripts()`.
266 *
267 * @return array{variant: string, disconnected: bool, gutenberg: bool, unified: bool, ciab: bool, enabled: bool}|null
268 */
269 private function get_active_context() {
270 // P2 frontends get neither the admin bar entry points nor the app.
271 $stylesheet = get_stylesheet();
272 $is_p2 = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
273
274 if ( ! is_admin() && $is_p2 ) {
275 return null;
276 }
277
278 // Determine which variant to load (null = don't load).
279 $variant = self::get_active_variant();
280 if ( null === $variant ) {
281 return null;
282 }
283
284 return array(
285 'variant' => $variant,
286 'disconnected' => str_contains( $variant, 'disconnected' ),
287 'gutenberg' => $this->is_block_editor(),
288 'unified' => self::is_unified_experience(),
289 'ciab' => $this->is_ciab_environment(),
290 'enabled' => self::is_enabled(),
291 );
292 }
293
294 /**
295 * Add the Agents Manager entry points to the admin bar.
296 *
297 * Hooked unconditionally, with eligibility resolved here rather than at registration, so the
298 * admin-bar REST endpoints — which fire `admin_bar_menu` with no enqueue hook — get the same
299 * nodes as a page load.
300 *
301 * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
302 */
303 public function add_admin_bar_nodes( $wp_admin_bar ) {
304 $context = $this->get_active_context();
305 if ( null === $context ) {
306 return;
307 }
308
309 // For non-Gutenberg, non-CIAB environments, add to the admin bar. Gutenberg uses JS when
310 // no admin bar is visible and the server-side branch below when one is available. CIAB
311 // hides the admin bar and uses its own Site Hub.
312 if ( ! $context['gutenberg'] && ! $context['ciab'] ) {
313 // Disconnected variants are Help-only. Full variants take over Help
314 // only when the unified experience is active.
315 if ( $context['disconnected'] || $context['unified'] ) {
316 $this->add_help_menu( $wp_admin_bar, $context['disconnected'] );
317 }
318
319 // Initialize the Help menu panel only for the full unified experience.
320 if ( ! $context['disconnected'] && $context['unified'] ) {
321 $this->add_menu_panel( $wp_admin_bar );
322 }
323
324 // Standalone AI chat button, shown whenever the full Agents Manager app is enabled.
325 if ( ! $context['disconnected'] && $context['enabled'] ) {
326 $this->add_ai_chat_button( $wp_admin_bar );
327 }
328 }
329
330 // When the block editor exposes the WordPress admin bar, add the entry points there.
331 // CIAB is excluded — it has its own Site Hub UI.
332 // Mirroring wp-admin: the Help "?" dropdown shows only in the full unified experience;
333 // the AI chat button shows whenever Agents Manager is enabled in this editor.
334 if ( ! $context['ciab'] && ! $context['disconnected'] && self::is_admin_bar_in_editor() ) {
335 // Help "?" node + dropdown panel first, matching the wp-admin admin bar order.
336 if ( $context['unified'] ) {
337 $this->add_help_menu( $wp_admin_bar, false );
338 $this->add_menu_panel( $wp_admin_bar );
339 }
340
341 if ( $context['enabled'] ) {
342 $this->add_ai_chat_button( $wp_admin_bar );
343 }
344 }
345 }
346
347 /**
348 * Enqueue Agents Manager scripts and add inline script data.
349 */
350 public function enqueue_scripts() {
351 $context = $this->get_active_context();
352 if ( null === $context ) {
353 return;
354 }
355
356 $variant = $context['variant'];
357
358 // In Gutenberg, dequeue Help Center so we don't end up with two buttons — but only
359 // in the full unified experience, where Agents Manager takes over the Help Center.
360 // In block-editor-only mode (e.g. ?flags=unified-big-sky) Agents Manager replaces
361 // Big Sky's native UI and Help Center should remain available.
362 // Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
363 if ( $context['gutenberg'] && $context['unified'] ) {
364 wp_dequeue_script( 'help-center' );
365 wp_dequeue_style( 'help-center-style' );
366 }
367
368 /**
369 * Filter to register agent provider modules for the Agents Manager.
370 *
371 * Plugins can hook into this filter to register script module IDs that export
372 * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
373 * import these modules and merge their providers.
374 *
375 * @param array $providers Array of provider script module IDs.
376 */
377 $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
378
379 /**
380 * Filter the default agent ID for the Agents Manager.
381 *
382 * Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
383 * workflow agent instead of the default orchestrator. The value is passed to
384 * the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
385 *
386 * @param string|null $agent_id The agent ID to use, or null for default behavior.
387 */
388 $agent_id = apply_filters( 'agents_manager_agent_id', null );
389
390 $script_version = $this->enqueue_script( $variant );
391
392 $inline_data = array(
393 'agentProviders' => $agent_providers,
394 'useUnifiedExperience' => $context['unified'],
395 'isDevMode' => self::is_dev_mode(),
396 'isA11n' => self::is_tracking_automattician(),
397 'isWpcomPlatform' => ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_platform(),
398 'sectionName' => apply_filters( 'agents_manager_section_name', $variant ),
399 'currentUser' => $this->get_current_user_data(),
400 'site' => $this->get_current_site(),
401 'helpCenterUrl' => self::HELP_CENTER_URL,
402 );
403
404 if ( null !== $script_version ) {
405 $inline_data['version'] = $variant . ':' . $script_version;
406 }
407
408 if ( $agent_id ) {
409 $inline_data['agentId'] = $agent_id;
410 }
411
412 /**
413 * Filter the data exposed to the Agents Manager frontend.
414 *
415 * @param array $inline_data Data encoded into `agentsManagerData`.
416 */
417 $filtered = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
418 $inline_data = is_array( $filtered ) ? $filtered : $inline_data;
419
420 wp_add_inline_script(
421 'agents-manager',
422 'const agentsManagerData = ' . wp_json_encode(
423 $inline_data,
424 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
425 ) . ';',
426 'before'
427 );
428 }
429
430 /**
431 * The script variant active for this request, or null if none.
432 *
433 * Single source of truth for "is the Agents Manager app loaded on this
434 * request?". Used both to enqueue the app and to gate the server-side
435 * sidebar pre-render, so the pre-rendered shell can never appear on a page
436 * where the app won't mount to reconcile it.
437 *
438 * @return string|null The variant name, or null if scripts should not be loaded.
439 */
440 public static function get_active_variant() {
441 if ( self::is_plugin_information_iframe() ) {
442 return null;
443 }
444
445 /**
446 * Filter the script variant the Agents Manager loads for this request.
447 *
448 * @since 0.1.0
449 *
450 * @param string|null $variant The resolved variant, or null to not load.
451 */
452 return apply_filters( 'agents_manager_variant', self::get_variant() );
453 }
454
455 /**
456 * Whether the current request renders the plugin information iframe.
457 *
458 * The parent plugin screen may load Agents Manager, but the iframe must not
459 * bootstrap a second copy of the app.
460 *
461 * @return bool
462 */
463 private static function is_plugin_information_iframe() {
464 global $current_screen;
465
466 if ( ! $current_screen || 'plugin-install' !== $current_screen->id ) {
467 return false;
468 }
469
470 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a request context check, not a form submission.
471 return isset( $_GET['tab'] ) && 'plugin-information' === sanitize_text_field( wp_unslash( $_GET['tab'] ) );
472 }
473
474 /**
475 * Determine which script variant to load, or null if none should be loaded.
476 *
477 * Combines the gating logic (should we load at all?) with variant selection
478 * (which build to use?) into a single method so the two cannot get out of sync.
479 *
480 * @return string|null The variant name, or null if scripts should not be loaded.
481 */
482 private static function get_variant() {
483 // CIAB: Load either the connected or disconnected variants if enabled.
484 if ( self::is_ciab_environment() && self::is_enabled() ) {
485 return self::is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
486 }
487
488 // Frontend: load disconnected variant for eligible logged-in editors.
489 if ( ! is_admin() ) {
490 if ( self::is_loading_on_frontend() && self::is_enabled() ) {
491 return 'wp-admin-disconnected';
492 }
493 return null;
494 }
495
496 // Apply wp-admin exclusions (customizer, asset, and preview contexts).
497 if ( ! self::passes_admin_checks() ) {
498 return null;
499 }
500
501 if ( ! self::is_enabled() ) {
502 return null;
503 }
504
505 $disconnected = self::is_jetpack_disconnected();
506
507 if ( self::is_block_editor() ) {
508 return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
509 }
510
511 return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
512 }
513
514 /**
515 * Whether the unified experience — the Help Center takeover — is active.
516 *
517 * "Unified" here means Agents Manager takes over the Help Center, unifying Odie and
518 * Dolly (the orchestrator) into a single chat experience. This is distinct from
519 * block-editor-only enablement, which replaces Big Sky's native UI without taking
520 * over the Help Center.
521 *
522 * @return bool
523 */
524 public static function is_unified_experience() {
525 /**
526 * Filter to determine if the user should see the unified chat experience.
527 *
528 * When true, Help Center will render UnifiedAIAgent instead of traditional UI.
529 * The filter is hooked by should_use_unified_experience() in this class.
530 *
531 * @param bool $use_unified_experience Whether to use unified experience. Default false.
532 */
533 return (bool) apply_filters( 'agents_manager_use_unified_experience', false );
534 }
535
536 /**
537 * Returns true if the Agents Manager should be loaded in the current context.
538 *
539 * @return bool
540 */
541 public static function is_enabled() {
542 $enabled = false;
543
544 // CIAB: Agents Manager is the default AI experience — enabled unless explicitly
545 // disabled via filter (e.g. for debugging or gradual rollout).
546 if ( self::is_ciab_environment() ) {
547 /**
548 * Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
549 *
550 * @param bool $enabled Whether Agents Manager should load. Default true.
551 */
552 $enabled = (bool) apply_filters( 'agents_manager_enabled_in_ciab', true );
553 } elseif ( self::is_unified_experience() ) {
554 // Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
555 $enabled = true;
556 } elseif ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
557 // Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
558 $enabled = true;
559 }
560
561 /**
562 * Filters whether an integration requests the Agents Manager shell on this request.
563 *
564 * Providers should preserve an existing true value so multiple integrations can
565 * request the shared shell independently.
566 *
567 * @since 0.9.1
568 *
569 * @param bool $should_load Whether another integration already requested the shell.
570 */
571 $should_load = (bool) apply_filters( 'agents_manager_should_load', false );
572
573 return $enabled || $should_load;
574 }
575
576 /**
577 * Returns true if the current wp-admin context passes all exclusion checks.
578 *
579 * Excludes customizer previews, Gutenberg asset requests, and preview query
580 * param contexts.
581 *
582 * @return bool
583 */
584 private static function passes_admin_checks() {
585 // Don't load in customizer preview iframe.
586 if ( is_customize_preview() ) {
587 return false;
588 }
589
590 // Don't load during Gutenberg asset requests or preview contexts.
591 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
592 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
593 $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
594 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
595 $is_preview_overlay = isset( $_GET['preview_overlay'] );
596 if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
597 return false;
598 }
599
600 return true;
601 }
602
603 /**
604 * Enqueue Agents Manager script based on context.
605 *
606 * @param string $variant The variant of the asset file to get.
607 * @return string|null The deployed build version from the asset file, or null when unavailable.
608 */
609 private function enqueue_script( $variant ) {
610 $cache_key = 'agents-manager-asset-' . $variant . '.asset.json';
611 $asset_file = get_transient( $cache_key );
612
613 if ( ! $asset_file ) {
614 $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
615 if ( ! $asset_file ) {
616 return null;
617 }
618 set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
619 }
620
621 // When the request is dev mode, use a random cache buster as the version for easier debugging.
622 $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
623
624 $script_dependencies = $asset_file['dependencies'] ?? array();
625
626 // Load translations for connected variants from widgets.wp.com.
627 // Disconnected variants have no translatable UI, so skip them (as Help
628 // Center does). English needs no translation file.
629 if ( ! str_contains( $variant, 'disconnected' ) ) {
630 $locale = self::determine_iso_639_locale();
631
632 if ( 'en' !== $locale ) {
633 wp_enqueue_script(
634 'agents-manager-translations',
635 'https://widgets.wp.com/agents-manager/languages/' . $locale . '-v1.js',
636 array( 'wp-i18n' ),
637 $version,
638 true
639 );
640
641 $script_dependencies[] = 'agents-manager-translations';
642 }
643 }
644
645 wp_enqueue_script(
646 'agents-manager',
647 'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
648 $script_dependencies,
649 $version,
650 /**
651 * Filter the strategy to use when enqueuing the script.
652 *
653 * @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
654 * @param string $handle The handle of the script.
655 */
656 apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
657 );
658
659 if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
660 wp_enqueue_style(
661 'agents-manager-style',
662 'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
663 array(),
664 $version
665 );
666 }
667
668 return (string) $asset_file['version'];
669 }
670
671 /**
672 * Returns the ISO 639 conforming locale string for the current user.
673 *
674 * Normalizes the WordPress user locale to match the widgets.wp.com translation
675 * file naming at languages/{code}-v1.js. Preserves the region for the few locales
676 * where it is meaningful (pt-br, zh-tw, zh-cn); strips the region for all others;
677 * falls back to 'en' when the locale is empty.
678 *
679 * @return string The ISO 639 locale string, e.g. "en".
680 */
681 private static function determine_iso_639_locale() {
682 $language = get_user_locale();
683 $language = strtolower( $language );
684
685 if ( in_array( $language, array( 'pt_br', 'pt-br', 'zh_tw', 'zh-tw', 'zh_cn', 'zh-cn' ), true ) ) {
686 $language = str_replace( '_', '-', $language );
687 } else {
688 $language = preg_replace( '/([-_].*)$/i', '', $language );
689 }
690
691 if ( empty( $language ) ) {
692 return 'en';
693 }
694
695 return $language;
696 }
697
698 /**
699 * Get the asset via file-system on wpcom and via network on Atomic sites.
700 *
701 * @param string $filepath The URL to download the asset file from.
702 * @return array|null The asset file data or null on failure.
703 */
704 private static function get_assets_json( $filepath ) {
705 $accessible_directly = file_exists( ABSPATH . $filepath );
706
707 if ( $accessible_directly ) {
708 $file_contents = file_get_contents( ABSPATH . $filepath );
709
710 if ( false === $file_contents ) {
711 return null;
712 }
713
714 return json_decode( $file_contents, true );
715 }
716
717 $request = wp_remote_get( 'https://' . $filepath );
718
719 if ( is_wp_error( $request ) ) {
720 return null;
721 }
722
723 $response_code = wp_remote_retrieve_response_code( $request );
724 if ( 200 !== $response_code ) {
725 return null;
726 }
727
728 $content_type = wp_remote_retrieve_header( $request, 'content-type' );
729 if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
730 return null;
731 }
732
733 $body = wp_remote_retrieve_body( $request );
734 if ( '' === $body ) {
735 return null;
736 }
737
738 $decoded = json_decode( $body, true );
739 if ( json_last_error() !== JSON_ERROR_NONE ) {
740 return null;
741 }
742
743 return $decoded;
744 }
745
746 /**
747 * Update the calypso preferences.
748 *
749 * @param \stdClass $preferences The preferences.
750 *
751 * @return \stdClass The preferences.
752 */
753 public function calypso_preferences_update( $preferences ) {
754 // Check if agents_manager_router_history exists and is a valid array structure
755 if ( ! isset( $preferences->agents_manager_router_history ) ||
756 ! is_array( $preferences->agents_manager_router_history ) ) {
757 return $preferences;
758 }
759
760 $router_history = $preferences->agents_manager_router_history;
761
762 // Check if entries exist and is an array
763 if ( ! isset( $router_history['entries'] ) ||
764 ! is_array( $router_history['entries'] ) ) {
765 return $preferences;
766 }
767
768 $entries = $router_history['entries'];
769
770 // Limit entries to 50 to prevent spamming entries in the router history.
771 if ( count( $entries ) > 50 ) {
772 // Keep only the last 49 entries and add the root entry at the beginning.
773 $entries = array_slice( $entries, -49 );
774 // Keep the start at root so the back button always works.
775 array_unshift(
776 $entries,
777 array(
778 'pathname' => '/',
779 'search' => '',
780 'hash' => '',
781 'key' => 'default',
782 'state' => null,
783 )
784 );
785
786 // Update the preferences object directly
787 $preferences->agents_manager_router_history['entries'] = $entries;
788 $preferences->agents_manager_router_history['index'] = 49;
789 }
790
791 return $preferences;
792 }
793
794 /**
795 * Creates instance.
796 *
797 * @return Agents_Manager
798 */
799 public static function init() {
800 if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
801 return self::get_instance();
802 }
803
804 self::$instance = new self();
805
806 /**
807 * Fires once the Agents Manager class has been instantiated.
808 *
809 * @since 0.5.0
810 */
811 do_action( 'jetpack_agents_manager_initialized' );
812
813 return self::$instance;
814 }
815
816 /**
817 * Returns the instance of the Agents Manager class.
818 *
819 * @return Agents_Manager
820 */
821 public static function get_instance() {
822 return self::$instance;
823 }
824
825 /**
826 * Returns whether the current request is coming from the A8C proxy.
827 *
828 * @return bool
829 */
830 private static function is_proxied() {
831 // On Simple sites, use the wpcom function if available.
832 if ( function_exists( 'wpcom_is_proxied_request' ) ) {
833 return wpcom_is_proxied_request();
834 }
835
836 // On WoA/Garden sites, check server variable or constant.
837 return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
838 ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
839 : Constants::is_true( 'A8C_PROXIED_REQUEST' );
840 }
841
842 /**
843 * Returns whether the current visitor should be marked as an Automattician in tracking.
844 *
845 * @return bool
846 */
847 private static function is_tracking_automattician() {
848 $is_automattician = function_exists( 'is_automattician' ) && (bool) is_automattician();
849
850 return $is_automattician || self::is_proxied() || Constants::is_true( 'AT_PROXIED_REQUEST' );
851 }
852
853 /**
854 * Enables "Development" features that should be accessible only for admins.
855 */
856 private static function is_dev_mode() {
857 // Known local environments.
858 $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
859 if (
860 $domain === 'localhost' ||
861 '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
862 '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
863 ) {
864 return true;
865 }
866
867 // A8C development.
868 if ( self::is_proxied() ) {
869 return true;
870 }
871
872 if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
873 switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
874 case 1:
875 case 2:
876 case 3: // Pressable
877 case 32:
878 case 118: // Commerce garden client (ciab)
879 return true;
880 }
881 }
882
883 return false;
884 }
885
886 /**
887 * Register the Agents Manager endpoints.
888 */
889 public function register_rest_api() {
890 ( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
891 ( new REST_Jetpack_AI_JWT() )->register_rest_route();
892 }
893
894 /**
895 * Determine if user should see unified experience.
896 *
897 * @param bool $use_unified_experience Whether to use unified experience.
898 * @return bool
899 */
900 public function should_use_unified_experience( $use_unified_experience = false ) {
901 // Early return for non-proxied/dev mode requests.
902 // This feature is currently only available to Automattic employees testing via proxy.
903 if ( ! self::is_dev_mode() ) {
904 return false;
905 }
906
907 $user_id = get_current_user_id();
908
909 if ( ! $user_id ) {
910 return false;
911 }
912
913 $is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
914 if ( $is_simple_site ) {
915 // On Simple sites, evaluate locally.
916 // Check Automattician and opt-in setting.
917 $is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
918 if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
919 return true;
920 }
921 }
922
923 // On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
924 // This avoids duplicating rollout logic and handles cases where
925 // wpcom-specific functions (like get_user_attribute) aren't available.
926 if ( $this->fetch_unified_experience_preference() ) {
927 return true;
928 }
929
930 // Default to false, for now.
931 // 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.
932 return $use_unified_experience;
933 }
934
935 /**
936 * Check if user has enabled unified chat opt-in in their Automattician options.
937 *
938 * This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
939 * Only used on Simple sites where get_user_attribute is available.
940 *
941 * @param int $user_id User ID.
942 *
943 * @return bool
944 */
945 private function has_unified_chat_opt_in_enabled( $user_id ) {
946 if ( ! function_exists( '\get_user_attribute' ) ) {
947 return false;
948 }
949
950 $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
951 return ! empty( $calypso_prefs['unified_ai_chat'] );
952 }
953
954 /**
955 * Fetch unified experience preference from wpcom via Jetpack Connection.
956 *
957 * Used on Atomic sites to delegate the decision to wpcom, which has
958 * access to user attributes and can evaluate the rollout logic.
959 *
960 * Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
961 *
962 * @return bool Whether user should see unified experience.
963 */
964 private function fetch_unified_experience_preference() {
965 $user_id = get_current_user_id();
966 if ( ! $user_id ) {
967 return false;
968 }
969
970 // Check transient cache first (per-user cache).
971 $cache_key = 'unified-experience-' . $user_id;
972 $cached_result = get_transient( $cache_key );
973 if ( false !== $cached_result ) {
974 return (bool) $cached_result;
975 }
976
977 // Check if user is connected before making API call.
978 if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
979 return false;
980 }
981
982 // Call dedicated agents-manager/state endpoint.
983 $wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
984 '/agents-manager/state?key=unified_ai_chat',
985 '2',
986 array( 'method' => 'GET' )
987 );
988
989 if ( is_wp_error( $wpcom_request ) ) {
990 // Cache failures too to avoid hammering the API.
991 set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
992 return false;
993 }
994
995 $response_code = wp_remote_retrieve_response_code( $wpcom_request );
996 if ( 200 !== $response_code ) {
997 set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
998 return false;
999 }
1000
1001 $body = wp_remote_retrieve_body( $wpcom_request );
1002 $decoded_body = json_decode( $body, true );
1003
1004 // The response is { "unified_ai_chat": true/false } when using key param.
1005 $result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
1006
1007 // Cache for 1 minute.
1008 set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
1009
1010 return $result;
1011 }
1012
1013 /**
1014 * Returns true if the current request is on the frontend and the user can edit posts.
1015 *
1016 * Mirrors Help_Center::is_loading_on_frontend().
1017 *
1018 * @return bool True if loading on the frontend for an eligible user.
1019 */
1020 private static function is_loading_on_frontend() {
1021 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
1022 if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
1023 return false;
1024 }
1025
1026 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
1027 if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
1028 return false;
1029 }
1030
1031 $can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
1032 return ! is_admin() && ! self::is_block_editor() && $can_edit_posts;
1033 }
1034
1035 /**
1036 * Returns true if the current screen is the block editor.
1037 *
1038 * @return bool True if the current screen is the block editor.
1039 */
1040 private static function is_block_editor() {
1041 if ( ! function_exists( 'get_current_screen' ) ) {
1042 return false;
1043 }
1044
1045 $current_screen = get_current_screen();
1046 // The widgets screen has the block editor but no Gutenberg top bar.
1047 return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
1048 }
1049
1050 /**
1051 * Returns true when the WordPress admin bar is available in the block editor.
1052 *
1053 * The frontend uses the visible admin bar as its signal to move editor entry points out of the
1054 * Gutenberg toolbar. Use WordPress's matching server-side signal so a classic editor admin bar
1055 * receives those entry points too, not only Gutenberg's experimental omnibar.
1056 *
1057 * @return bool
1058 */
1059 private static function is_admin_bar_in_editor() {
1060 return self::is_block_editor() && is_admin_bar_showing();
1061 }
1062
1063 /**
1064 * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
1065 *
1066 * Uses the same detection method as Help Center: checks if next_admin_init has fired.
1067 *
1068 * @return bool True if CIAB/Next Admin environment.
1069 */
1070 private static function is_ciab_environment() {
1071 return (bool) did_action( 'next_admin_init' );
1072 }
1073
1074 /**
1075 * Returns true if the current user is NOT connected through Jetpack.
1076 *
1077 * Mirrors the logic from Help_Center::is_jetpack_disconnected().
1078 *
1079 * @return bool True if the site uses Jetpack but the current user is not connected.
1080 */
1081 private static function is_jetpack_disconnected() {
1082 $user_id = get_current_user_id();
1083 $blog_id = get_current_blog_id();
1084
1085 if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
1086 return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
1087 }
1088
1089 if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
1090 return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
1091 }
1092
1093 return false;
1094 }
1095
1096 /**
1097 * Get current user data for the agents manager.
1098 *
1099 * Mirrors the user data structure from Help Center's helpCenterData.
1100 *
1101 * @return array|null User data array or null if not logged in.
1102 */
1103 private function get_current_user_data() {
1104 $user_id = get_current_user_id();
1105 if ( ! $user_id ) {
1106 return null;
1107 }
1108
1109 $user_data = get_userdata( $user_id );
1110 if ( ! $user_data ) {
1111 return null;
1112 }
1113
1114 $user_email = $user_data->user_email;
1115
1116 // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
1117 if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1118 $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
1119 } else {
1120 $avatar_url = get_avatar_url( $user_id );
1121 }
1122
1123 return array(
1124 'ID' => $user_id,
1125 'username' => $user_data->user_login,
1126 'display_name' => $user_data->display_name,
1127 'avatar_URL' => $avatar_url,
1128 'email' => $user_email,
1129 );
1130 }
1131
1132 /**
1133 * Get current site data for the agents manager.
1134 *
1135 * Returns minimal site data needed by AgentsManager (ID and domain only).
1136 * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
1137 *
1138 * @return array Site data with ID and domain.
1139 */
1140 private function get_current_site() {
1141 /*
1142 * Atomic sites have the WP.com blog ID stored as a Jetpack option.
1143 * This code deliberately doesn't use `Jetpack_Options::get_option`
1144 * so it works even when Jetpack has not been loaded.
1145 */
1146 $jetpack_options = get_option( 'jetpack_options' );
1147 if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
1148 $site_id = (int) $jetpack_options['id'];
1149 } else {
1150 $site_id = get_current_blog_id();
1151 }
1152
1153 return array(
1154 'ID' => $site_id,
1155 'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
1156 );
1157 }
1158 }
1159