PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.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
← All changes | extensions/plugins/ai-assistant-plugin/reader-chat/class-jetpack-reader-chat.php +516 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,516 @@
1 +<?php
2 +/**
3 + * Jetpack Reader Chat — Agents Manager CDN loader for blog readers.
4 + *
5 + * Loads a self-contained reader-chat bundle from the widgets.wp.com CDN
6 + * and renders a floating chat UI on singular posts for logged-out visitors.
7 + *
8 + * The reader-chat bundle inlines all WP dependencies (built without
9 + * DependencyExtractionWebpackPlugin) so it works on the frontend
10 + * without WordPress's script loader.
11 + *
12 + * Enable via filter:
13 + * add_filter( 'jetpack_reader_chat_enabled', '__return_true' );
14 + *
15 + * @package automattic/jetpack
16 + */
17 +
18 +namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
19 +
20 +use Automattic\Jetpack\Connection\Manager as Connection_Manager;
21 +use Automattic\Jetpack\Search\Helper as Search_Helper;
22 +use Automattic\Jetpack\Search\Plan;
23 +use Automattic\Jetpack\Status;
24 +use Automattic\Jetpack\Status\Host;
25 +use Jetpack_Options;
26 +
27 +if ( ! defined( 'ABSPATH' ) ) {
28 + exit( 0 );
29 +}
30 +
31 +const READER_CHAT_JS_URL = 'https://widgets.wp.com/agents-manager/reader-chat.min.js';
32 +const READER_CHAT_ASSET_TRANSIENT = 'jetpack_reader_chat_asset';
33 +const READER_CHAT_ASSET_FAILURE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
34 +
35 +/**
36 + * Handles loading the reader chat UI on the frontend.
37 + */
38 +class Jetpack_Reader_Chat {
39 +
40 + /**
41 + * Initialize hooks.
42 + *
43 + * @return void
44 + */
45 + public static function init(): void {
46 + // Register the setting unconditionally so the Search REST endpoint can
47 + // flip it even when the feature is currently disabled.
48 + add_action( 'init', array( __CLASS__, 'register_settings' ) );
49 + add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) );
50 +
51 + /**
52 + * Filter to enable or disable the Jetpack Reader Chat feature.
53 + *
54 + * Defaults to the value of the reader_chat site option (false when
55 + * unset). Override programmatically with:
56 + * add_filter( 'jetpack_reader_chat_enabled', '__return_true' );
57 + *
58 + * @since 15.9
59 + *
60 + * @param bool $enabled Whether the reader chat is enabled.
61 + */
62 + if ( ! apply_filters( 'jetpack_reader_chat_enabled', (bool) get_option( 'reader_chat', false ) ) ) {
63 + return;
64 + }
65 +
66 + /**
67 + * Filter whether Reader Chat should hook its public frontend loader.
68 + *
69 + * @since 15.9
70 + *
71 + * @param bool $enabled Whether the reader chat frontend loader should be hooked.
72 + */
73 + if ( ! apply_filters( 'jetpack_reader_chat_enqueue_enabled', true ) ) {
74 + return;
75 + }
76 +
77 + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
78 + add_action( 'wp_footer', array( __CLASS__, 'render_mount_div' ) );
79 + }
80 +
81 + /**
82 + * Register the reader_chat option so Search settings can read and write it.
83 + *
84 + * @since 15.9
85 + *
86 + * @return void
87 + */
88 + public static function register_settings(): void {
89 + register_setting(
90 + 'jetpack_search',
91 + 'reader_chat',
92 + array(
93 + 'type' => 'boolean',
94 + 'description' => __( 'Whether Site Chat is enabled on this site.', 'jetpack' ),
95 + 'sanitize_callback' => 'rest_sanitize_boolean',
96 + 'default' => false,
97 + )
98 + );
99 + }
100 +
101 + /**
102 + * Add Reader Chat's setting to Jetpack Sync's option whitelist.
103 + *
104 + * Atomic and Jurassic Ninja sites write `reader_chat` locally via
105 + * Search settings, while the wpcom-hosted agent reads the wpcom-side option
106 + * before serving public chat requests. Syncing the option keeps
107 + * the local toggle and agent permission gate aligned.
108 + *
109 + * @since 15.9
110 + *
111 + * @param array $options Option names allowed to sync.
112 + * @return array Updated option names.
113 + */
114 + public static function add_sync_options_whitelist( array $options ): array {
115 + $options[] = 'reader_chat';
116 + return array_values( array_unique( $options ) );
117 + }
118 +
119 + /**
120 + * Enqueue the reader chat script on the frontend.
121 + *
122 + * Loads on every public-facing page (home, archives, pages, singular
123 + * posts). Skips admin, feeds, and AJAX to keep the bundle off contexts
124 + * where the chat UI doesn't belong. currentPost in the config is only
125 + * populated on singular views — stream views get general suggestions.
126 + *
127 + * @return void
128 + */
129 + public static function enqueue_scripts(): void {
130 + if ( is_admin() || is_feed() || wp_doing_ajax() ) {
131 + return;
132 + }
133 +
134 + if ( self::is_site_coming_soon_or_unlaunched() ) {
135 + return;
136 + }
137 +
138 + /**
139 + * Filter to override the AI features check.
140 + *
141 + * Set to true to load reader chat regardless of Jetpack connection
142 + * status, or false to force-disable. Defaults to null, meaning use
143 + * the built-in check. Useful for testing on dev sites.
144 + *
145 + * @param bool|null $override null = use default check, true/false = override.
146 + */
147 + $has_features = apply_filters( 'jetpack_reader_chat_has_ai_features', null );
148 + if ( ! ( $has_features ?? self::has_ai_features() ) ) {
149 + return;
150 + }
151 +
152 + if ( ! self::has_search_plan_access() ) {
153 + return;
154 + }
155 +
156 + $version = self::get_asset_version();
157 +
158 + // The reader-chat bundle is self-contained — no WP script dependencies.
159 + wp_enqueue_script(
160 + 'jetpack-reader-chat',
161 + READER_CHAT_JS_URL,
162 + array(),
163 + $version,
164 + true
165 + );
166 +
167 + wp_enqueue_style(
168 + 'jetpack-reader-chat',
169 + 'https://widgets.wp.com/agents-manager/reader-chat.css',
170 + array(),
171 + $version
172 + );
173 +
174 + // Inject config for the JS bundle (before the script tag).
175 + wp_add_inline_script(
176 + 'jetpack-reader-chat',
177 + 'window.JetpackReaderChatConfig = ' . wp_json_encode(
178 + self::get_reader_chat_config(),
179 + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
180 + ) . ';',
181 + 'before'
182 + );
183 + }
184 +
185 + /**
186 + * Check whether the current site is Coming Soon or unlaunched.
187 + *
188 + * Reader Chat is a public frontend widget, so it should not mount on sites
189 + * that are still hidden behind launch or Coming Soon visibility.
190 + *
191 + * @return bool Whether the site is hidden by launch or Coming Soon visibility.
192 + */
193 + private static function is_site_coming_soon_or_unlaunched(): bool {
194 + $status = new Status();
195 + if ( $status->is_coming_soon() ) {
196 + return true;
197 + }
198 +
199 + return 'unlaunched' === get_option( 'launch-status' );
200 + }
201 +
202 + /**
203 + * Render the mount div in the footer.
204 + *
205 + * Only outputs the div when the script was successfully enqueued.
206 + *
207 + * @return void
208 + */
209 + public static function render_mount_div(): void {
210 + if ( ! wp_script_is( 'jetpack-reader-chat' ) ) {
211 + return;
212 + }
213 +
214 + echo '<div id="jetpack-reader-chat"></div>';
215 + }
216 +
217 + /**
218 + * Build the config object for the reader chat JS bundle.
219 + *
220 + * @return array The config array for JSON encoding.
221 + */
222 + private static function get_reader_chat_config(): array {
223 + $host = new Host();
224 + if ( $host->is_wpcom_simple() ) {
225 + $site_id = get_current_blog_id();
226 + } else {
227 + $site_id = (int) Jetpack_Options::get_option( 'id' );
228 + }
229 +
230 + $config = array(
231 + 'siteId' => $site_id,
232 + 'siteUrl' => home_url(),
233 + 'siteName' => get_bloginfo( 'name' ),
234 + 'isDevMode' => self::is_dev_mode(),
235 + 'agentId' => 'reader-chat',
236 + );
237 +
238 + $current_post = self::get_current_post_context();
239 + if ( null !== $current_post ) {
240 + $config['currentPost'] = $current_post;
241 + }
242 +
243 + return $config;
244 + }
245 +
246 + /**
247 + * Build the current post context for the reader chat config.
248 + *
249 + * Returns null on non-singular views or when no post is available.
250 + *
251 + * @return array|null Post context, or null when not on a singular view.
252 + */
253 + private static function get_current_post_context(): ?array {
254 + if ( ! is_singular() ) {
255 + return null;
256 + }
257 +
258 + $post = get_post();
259 + if ( ! $post ) {
260 + return null;
261 + }
262 +
263 + // Only expose current post context for content that is publicly
264 + // viewable. Draft/private/future/trash posts can be visible to
265 + // editors through previews, but reader chat is public-facing and
266 + // should not receive non-public post content in its inline config.
267 + if ( is_preview() || ! is_post_publicly_viewable( $post ) ) {
268 + return null;
269 + }
270 +
271 + // Respect password-protected posts: do not leak body content to
272 + // visitors who have not entered the password. Omit the whole
273 + // currentPost envelope so the chat doesn't imply it "knows" the
274 + // post's content either.
275 + if ( post_password_required( $post ) ) {
276 + return null;
277 + }
278 +
279 + $context = array(
280 + 'id' => $post->ID,
281 + 'title' => get_the_title( $post ),
282 + 'url' => get_permalink( $post ),
283 + 'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 120 ),
284 + 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ),
285 + 'date' => get_the_date( 'F j, Y', $post ),
286 + );
287 +
288 + $categories = get_the_category( $post->ID );
289 + if ( $categories ) {
290 + $context['categories'] = wp_list_pluck( $categories, 'name' );
291 + }
292 +
293 + $tags = get_the_tags( $post->ID );
294 + if ( $tags ) {
295 + $context['tags'] = wp_list_pluck( $tags, 'name' );
296 + }
297 +
298 + return $context;
299 + }
300 +
301 + /**
302 + * Get the version string for the CDN bundle.
303 + *
304 + * Attempts to read the version from the remote asset manifest.
305 + * Falls back to a timestamp in dev mode, or null in production.
306 + *
307 + * @return string|false|null The version string, or null to omit the query param.
308 + */
309 + private static function get_asset_version() {
310 + $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
311 +
312 + if ( ! $skip_cache ) {
313 + $cached = get_transient( READER_CHAT_ASSET_TRANSIENT );
314 + if ( false !== $cached ) {
315 + return $cached['version'] ?? null;
316 + }
317 + }
318 +
319 + $json_path = 'widgets.wp.com/agents-manager/reader-chat.asset.json';
320 +
321 + // Try local filesystem first (available on WordPress.com).
322 + $data = self::read_local_asset_json( ABSPATH . $json_path );
323 +
324 + // Fallback to HTTP fetch.
325 + if ( false === $data ) {
326 + $data = self::fetch_remote_asset_json( 'https://' . $json_path );
327 + }
328 +
329 + if ( false === $data ) {
330 + // Dev mode: return a cache-busting version so the sandbox bundle loads.
331 + if ( self::is_dev_mode() ) {
332 + return 'dev-' . time();
333 + }
334 + if ( ! $skip_cache ) {
335 + set_transient(
336 + READER_CHAT_ASSET_TRANSIENT,
337 + array(
338 + 'version' => null,
339 + ),
340 + READER_CHAT_ASSET_FAILURE_CACHE_TTL
341 + );
342 + }
343 + return null;
344 + }
345 +
346 + if ( ! $skip_cache ) {
347 + set_transient( READER_CHAT_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
348 + }
349 +
350 + return $data['version'] ?? null;
351 + }
352 +
353 + /**
354 + * Read and decode a local asset manifest JSON file.
355 + *
356 + * @param string $path Absolute filesystem path to the JSON file.
357 + * @return array|false Decoded data or false on failure.
358 + */
359 + private static function read_local_asset_json( string $path ) {
360 + if ( ! file_exists( $path ) ) {
361 + return false;
362 + }
363 +
364 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, not remote URL.
365 + $contents = file_get_contents( $path );
366 + if ( false === $contents ) {
367 + return false;
368 + }
369 +
370 + return self::decode_asset_json( $contents );
371 + }
372 +
373 + /**
374 + * Fetch and decode a remote asset manifest JSON file.
375 + *
376 + * @param string $url URL to fetch.
377 + * @return array|false Decoded data or false on failure.
378 + */
379 + private static function fetch_remote_asset_json( string $url ) {
380 + $response = wp_safe_remote_get( $url );
381 + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
382 + return false;
383 + }
384 +
385 + return self::decode_asset_json( wp_remote_retrieve_body( $response ) );
386 + }
387 +
388 + /**
389 + * Decode a JSON asset manifest string and validate the result is an array.
390 + *
391 + * @param string $contents Raw JSON string.
392 + * @return array|false Decoded array or false on decode failure / non-array.
393 + */
394 + private static function decode_asset_json( string $contents ) {
395 + $data = json_decode( $contents, true );
396 + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
397 + return false;
398 + }
399 + return $data;
400 + }
401 +
402 + /**
403 + * Check whether AI features are available for this site.
404 + *
405 + * @return bool
406 + */
407 + private static function has_ai_features(): bool {
408 + $host = new Host();
409 +
410 + if ( $host->is_wpcom_simple() ) {
411 + return true;
412 + }
413 +
414 + return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
415 + && ! ( new Status() )->is_offline_mode()
416 + && \Jetpack_AI_Settings::is_ai_enabled();
417 + }
418 +
419 + /**
420 + * Check whether the current site can serve Reader Chat under its Search plan.
421 + *
422 + * Uses WordPress.com's local Search plan source on Simple sites. Elsewhere,
423 + * uses the cached Jetpack Search plan option instead of forcing a remote
424 + * refresh on public frontend requests.
425 + *
426 + * @return bool
427 + */
428 + private static function has_search_plan_access(): bool {
429 + $plan_access = apply_filters( 'jetpack_reader_chat_has_search_plan_access', null );
430 + if ( null !== $plan_access ) {
431 + return (bool) $plan_access;
432 + }
433 +
434 + $host = new Host();
435 + if ( $host->is_wpcom_simple() ) {
436 + $blog_id = get_current_blog_id();
437 + if ( $blog_id <= 0 ) {
438 + return false;
439 + }
440 +
441 + if ( function_exists( 'require_lib' ) ) {
442 + require_lib( 'jetpack-search' );
443 + }
444 +
445 + $wpcom_plan_info_class = '\Jetpack\Search\Plan_Info';
446 + if ( class_exists( $wpcom_plan_info_class ) ) {
447 + $plan_info = new $wpcom_plan_info_class( $blog_id );
448 + return $plan_info->supports_search()
449 + && method_exists( $plan_info, 'is_free_search_plan' )
450 + && ! $plan_info->is_free_search_plan()
451 + && ! $plan_info->is_disabled_due_to_overage();
452 + }
453 + }
454 +
455 + if ( ! class_exists( Plan::class ) ) {
456 + return false;
457 + }
458 +
459 + $plan_info = get_option( Plan::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY );
460 + if ( ! is_array( $plan_info ) ) {
461 + return false;
462 + }
463 +
464 + return ! Search_Helper::is_forced_free_plan()
465 + && ! empty( $plan_info['supports_search'] )
466 + && ( $plan_info['effective_subscription']['product_slug'] ?? null ) !== Plan::JETPACK_SEARCH_FREE_PRODUCT_SLUG
467 + && empty( $plan_info['plan_usage']['must_upgrade'] );
468 + }
469 +
470 + /**
471 + * Check if the current request is from a development environment.
472 + *
473 + * Matches the pattern used in Jetpack_AI_Sidebar::is_dev_mode().
474 + * IMPORTANT: Only use for feature gating, not authorization.
475 + *
476 + * @return bool
477 + */
478 + private static function is_dev_mode(): bool {
479 + $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
480 + if ( ! is_string( $domain ) ) {
481 + return false;
482 + }
483 +
484 + if (
485 + 'localhost' === $domain ||
486 + '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
487 + '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
488 + ) {
489 + return true;
490 + }
491 +
492 + if ( function_exists( 'wpcom_is_proxied_request' ) && wpcom_is_proxied_request() ) {
493 + return true;
494 + }
495 +
496 + if (
497 + ( isset( $_SERVER['A8C_PROXIED_REQUEST'] ) && (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) ) ) ||
498 + ( defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST )
499 + ) {
500 + return true;
501 + }
502 +
503 + if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST && defined( 'ATOMIC_CLIENT_ID' ) ) {
504 + switch ( ATOMIC_CLIENT_ID ) {
505 + case 1:
506 + case 2:
507 + case 3: // Pressable
508 + case 32:
509 + case 118: // Commerce garden client (ciab)
510 + return true;
511 + }
512 + }
513 +
514 + return false;
515 + }
516 +}