Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.24% covered (warning)
86.24%
163 / 189
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Reader_Chat
87.17% covered (warning)
87.17%
163 / 187
53.33% covered (warning)
53.33%
8 / 15
96.21
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 register_settings
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 add_sync_options_whitelist
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_scripts
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 is_site_coming_soon_or_unlaunched
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 render_mount_div
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_reader_chat_config
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 get_current_post_context
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_asset_version
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
9.37
 read_local_asset_json
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 fetch_remote_asset_json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 decode_asset_json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 has_ai_features
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 has_search_plan_access
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
14.09
 is_dev_mode
50.00% covered (danger)
50.00%
10 / 20
0.00% covered (danger)
0.00%
0 / 1
64.12
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
18namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
19
20use Automattic\Jetpack\Connection\Manager as Connection_Manager;
21use Automattic\Jetpack\Search\Helper as Search_Helper;
22use Automattic\Jetpack\Search\Plan;
23use Automattic\Jetpack\Status;
24use Automattic\Jetpack\Status\Host;
25use Jetpack_Options;
26
27if ( ! defined( 'ABSPATH' ) ) {
28    exit( 0 );
29}
30
31const READER_CHAT_JS_URL                  = 'https://widgets.wp.com/agents-manager/reader-chat.min.js';
32const READER_CHAT_ASSET_TRANSIENT         = 'jetpack_reader_chat_asset';
33const READER_CHAT_ASSET_FAILURE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
34
35/**
36 * Handles loading the reader chat UI on the frontend.
37 */
38class 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}