Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.13% covered (warning)
87.13%
176 / 202
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Reader_Chat
88.00% covered (warning)
88.00%
176 / 200
61.11% covered (warning)
61.11%
11 / 18
104.00
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%
30 / 30
100.00% covered (success)
100.00%
1 / 1
9
 get_load_strategy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add_resource_hints
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_deferred_loader_script
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 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 * The bundle is deferred until the visitor interacts with the page; see
16 * the `jetpack_reader_chat_load_strategy` filter.
17 *
18 * @package automattic/jetpack
19 */
20
21namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
22
23use Automattic\Jetpack\Connection\Manager as Connection_Manager;
24use Automattic\Jetpack\Search\Helper as Search_Helper;
25use Automattic\Jetpack\Search\Plan;
26use Automattic\Jetpack\Status;
27use Automattic\Jetpack\Status\Host;
28use Jetpack_Options;
29
30if ( ! defined( 'ABSPATH' ) ) {
31    exit( 0 );
32}
33
34const READER_CHAT_JS_URL                  = 'https://widgets.wp.com/agents-manager/reader-chat.min.js';
35const READER_CHAT_CSS_URL                 = 'https://widgets.wp.com/agents-manager/reader-chat.css';
36const READER_CHAT_ASSET_TRANSIENT         = 'jetpack_reader_chat_asset';
37const READER_CHAT_ASSET_FAILURE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
38
39/**
40 * Handles loading the reader chat UI on the frontend.
41 */
42class Jetpack_Reader_Chat {
43
44    /**
45     * Initialize hooks.
46     *
47     * @return void
48     */
49    public static function init(): void {
50        // Register the setting unconditionally so the Search REST endpoint can
51        // flip it even when the feature is currently disabled.
52        add_action( 'init', array( __CLASS__, 'register_settings' ) );
53        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) );
54
55        /**
56         * Filter to enable or disable the Jetpack Reader Chat feature.
57         *
58         * Defaults to the value of the reader_chat site option (false when
59         * unset). Override programmatically with:
60         *   add_filter( 'jetpack_reader_chat_enabled', '__return_true' );
61         *
62         * @since 15.9
63         *
64         * @param bool $enabled Whether the reader chat is enabled.
65         */
66        if ( ! apply_filters( 'jetpack_reader_chat_enabled', (bool) get_option( 'reader_chat', false ) ) ) {
67            return;
68        }
69
70        /**
71         * Filter whether Reader Chat should hook its public frontend loader.
72         *
73         * @since 15.9
74         *
75         * @param bool $enabled Whether the reader chat frontend loader should be hooked.
76         */
77        if ( ! apply_filters( 'jetpack_reader_chat_enqueue_enabled', true ) ) {
78            return;
79        }
80
81        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
82        add_action( 'wp_footer', array( __CLASS__, 'render_mount_div' ) );
83    }
84
85    /**
86     * Register the reader_chat option so Search settings can read and write it.
87     *
88     * @since 15.9
89     *
90     * @return void
91     */
92    public static function register_settings(): void {
93        register_setting(
94            'jetpack_search',
95            'reader_chat',
96            array(
97                'type'              => 'boolean',
98                'description'       => __( 'Whether Site Chat is enabled on this site.', 'jetpack' ),
99                'sanitize_callback' => 'rest_sanitize_boolean',
100                'default'           => false,
101            )
102        );
103    }
104
105    /**
106     * Add Reader Chat's setting to Jetpack Sync's option whitelist.
107     *
108     * Atomic and Jurassic Ninja sites write `reader_chat` locally via
109     * Search settings, while the wpcom-hosted agent reads the wpcom-side option
110     * before serving public chat requests. Syncing the option keeps
111     * the local toggle and agent permission gate aligned.
112     *
113     * @since 15.9
114     *
115     * @param array $options Option names allowed to sync.
116     * @return array Updated option names.
117     */
118    public static function add_sync_options_whitelist( array $options ): array {
119        $options[] = 'reader_chat';
120        return array_values( array_unique( $options ) );
121    }
122
123    /**
124     * Enqueue the reader chat script on the frontend.
125     *
126     * Loads on every public-facing page (home, archives, pages, singular
127     * posts). Skips admin, feeds, and AJAX to keep the bundle off contexts
128     * where the chat UI doesn't belong. currentPost in the config is only
129     * populated on singular views — stream views get general suggestions.
130     *
131     * @return void
132     */
133    public static function enqueue_scripts(): void {
134        if ( is_admin() || is_feed() || wp_doing_ajax() ) {
135            return;
136        }
137
138        if ( self::is_site_coming_soon_or_unlaunched() ) {
139            return;
140        }
141
142        /**
143         * Filter to override the AI features check.
144         *
145         * Set to true to load reader chat regardless of Jetpack connection
146         * status, or false to force-disable. Defaults to null, meaning use
147         * the built-in check. Useful for testing on dev sites.
148         *
149         * @param bool|null $override null = use default check, true/false = override.
150         */
151        $has_features = apply_filters( 'jetpack_reader_chat_has_ai_features', null );
152        if ( ! ( $has_features ?? self::has_ai_features() ) ) {
153            return;
154        }
155
156        if ( ! self::has_search_plan_access() ) {
157            return;
158        }
159
160        $version  = self::get_asset_version();
161        $strategy = self::get_load_strategy();
162        $eager    = 'eager' === $strategy;
163
164        // The reader-chat bundle is self-contained — no WP script dependencies. A src-less
165        // handle prints only its inline scripts, so deferred modes leave the fetch to the loader.
166        wp_register_script( 'jetpack-reader-chat', $eager ? READER_CHAT_JS_URL : false, array(), $version, true );
167        wp_enqueue_script( 'jetpack-reader-chat' );
168
169        if ( $eager ) {
170            wp_enqueue_style( 'jetpack-reader-chat', READER_CHAT_CSS_URL, array(), $version );
171        } else {
172            wp_add_inline_script(
173                'jetpack-reader-chat',
174                self::get_deferred_loader_script( 'idle' === $strategy, $version ),
175                'after'
176            );
177            add_filter( 'wp_resource_hints', array( __CLASS__, 'add_resource_hints' ), 10, 2 );
178        }
179
180        // Inject config for the JS bundle (before the script tag).
181        wp_add_inline_script(
182            'jetpack-reader-chat',
183            'window.JetpackReaderChatConfig = ' . wp_json_encode(
184                self::get_reader_chat_config(),
185                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
186            ) . ';',
187            'before'
188        );
189    }
190
191    /**
192     * Get when the frontend should download and run the reader chat bundle.
193     *
194     * @return string 'interaction', 'idle', or 'eager'.
195     */
196    private static function get_load_strategy(): string {
197        /**
198         * Filter when the Site Chat bundle loads on the frontend.
199         *
200         * - 'interaction' (default): on the visitor's first scroll, pointer, touch, key, or focus event.
201         * - 'idle': once the page has finished loading, or on first interaction if that comes sooner.
202         * - 'eager': with the page, as a regular script and stylesheet.
203         *
204         * @since $$next-version$$
205         *
206         * @param string $strategy The load strategy.
207         */
208        $strategy = apply_filters( 'jetpack_reader_chat_load_strategy', 'interaction' );
209
210        return in_array( $strategy, array( 'idle', 'eager' ), true ) ? $strategy : 'interaction';
211    }
212
213    /**
214     * Restore the DNS prefetch WordPress adds for enqueued CDN assets, which a src-less handle loses.
215     *
216     * @param array  $urls          URLs to print for the relation type.
217     * @param string $relation_type The relation type the URLs are printed for.
218     * @return array Updated URLs.
219     */
220    public static function add_resource_hints( $urls, $relation_type ) {
221        if ( 'dns-prefetch' === $relation_type ) {
222            $urls[] = '//widgets.wp.com';
223        }
224        return $urls;
225    }
226
227    /**
228     * Build the inline script that injects the reader chat stylesheet and bundle on demand.
229     *
230     * @param bool             $idle    Whether to also load once the page is idle.
231     * @param string|null|bool $version Asset version, or null to omit the query param.
232     * @return string Inline JavaScript.
233     */
234    private static function get_deferred_loader_script( bool $idle, $version ): string {
235        $ver      = $version ? rawurlencode( $version ) : null;
236        $settings = array(
237            'js'   => add_query_arg( 'ver', $ver, READER_CHAT_JS_URL ),
238            'css'  => add_query_arg( 'ver', $ver, READER_CHAT_CSS_URL ),
239            'idle' => $idle,
240        );
241
242        $loader = <<<'JS'
243( function ( s ) {
244    var events = [ 'pointerdown', 'pointermove', 'keydown', 'touchstart', 'scroll', 'wheel', 'focusin' ];
245    var started = false;
246    function load() {
247        if ( started ) {
248            return;
249        }
250        started = true;
251        events.forEach( function ( e ) {
252            window.removeEventListener( e, load, true );
253        } );
254        // Fetch the bundle alongside the styles, then run it once the styles are in.
255        var preload = document.createElement( 'link' );
256        preload.rel = 'preload';
257        preload.as = 'script';
258        preload.href = s.js;
259        document.head.appendChild( preload );
260        var link = document.createElement( 'link' );
261        link.rel = 'stylesheet';
262        link.href = s.css;
263        link.onload = link.onerror = function () {
264            var script = document.createElement( 'script' );
265            script.src = s.js;
266            document.body.appendChild( script );
267        };
268        document.head.appendChild( link );
269    }
270    // The bundle sets this key while the chat is open, so keep an open chat open across page views.
271    try {
272        var agentId = ( window.JetpackReaderChatConfig || {} ).agentId;
273        if ( '1' === window.sessionStorage.getItem( 'jetpack-reader-chat-open-' + agentId ) ) {
274            return load();
275        }
276    } catch ( e ) {}
277    events.forEach( function ( e ) {
278        window.addEventListener( e, load, { capture: true, passive: true } );
279    } );
280    if ( s.idle ) {
281        var whenIdle = function () {
282            if ( window.requestIdleCallback ) {
283                window.requestIdleCallback( load, { timeout: 2000 } );
284            } else {
285                setTimeout( load, 1 );
286            }
287        };
288        if ( 'complete' === document.readyState ) {
289            whenIdle();
290        } else {
291            window.addEventListener( 'load', whenIdle );
292        }
293    }
294} )(
295JS;
296
297        return $loader . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
298    }
299
300    /**
301     * Check whether the current site is Coming Soon or unlaunched.
302     *
303     * Reader Chat is a public frontend widget, so it should not mount on sites
304     * that are still hidden behind launch or Coming Soon visibility.
305     *
306     * @return bool Whether the site is hidden by launch or Coming Soon visibility.
307     */
308    private static function is_site_coming_soon_or_unlaunched(): bool {
309        $status = new Status();
310        if ( $status->is_coming_soon() ) {
311            return true;
312        }
313
314        return 'unlaunched' === get_option( 'launch-status' );
315    }
316
317    /**
318     * Render the mount div in the footer.
319     *
320     * Only outputs the div when the script was successfully enqueued.
321     *
322     * @return void
323     */
324    public static function render_mount_div(): void {
325        if ( ! wp_script_is( 'jetpack-reader-chat' ) ) {
326            return;
327        }
328
329        echo '<div id="jetpack-reader-chat"></div>';
330    }
331
332    /**
333     * Build the config object for the reader chat JS bundle.
334     *
335     * @return array The config array for JSON encoding.
336     */
337    private static function get_reader_chat_config(): array {
338        $host = new Host();
339        if ( $host->is_wpcom_simple() ) {
340            $site_id = get_current_blog_id();
341        } else {
342            $site_id = (int) Jetpack_Options::get_option( 'id' );
343        }
344
345        $config = array(
346            'siteId'    => $site_id,
347            'siteUrl'   => home_url(),
348            'siteName'  => get_bloginfo( 'name' ),
349            'isDevMode' => self::is_dev_mode(),
350            'agentId'   => 'reader-chat',
351        );
352
353        $current_post = self::get_current_post_context();
354        if ( null !== $current_post ) {
355            $config['currentPost'] = $current_post;
356        }
357
358        return $config;
359    }
360
361    /**
362     * Build the current post context for the reader chat config.
363     *
364     * Returns null on non-singular views or when no post is available.
365     *
366     * @return array|null Post context, or null when not on a singular view.
367     */
368    private static function get_current_post_context(): ?array {
369        if ( ! is_singular() ) {
370            return null;
371        }
372
373        $post = get_post();
374        if ( ! $post ) {
375            return null;
376        }
377
378        // Only expose current post context for content that is publicly
379        // viewable. Draft/private/future/trash posts can be visible to
380        // editors through previews, but reader chat is public-facing and
381        // should not receive non-public post content in its inline config.
382        if ( is_preview() || ! is_post_publicly_viewable( $post ) ) {
383            return null;
384        }
385
386        // Respect password-protected posts: do not leak body content to
387        // visitors who have not entered the password. Omit the whole
388        // currentPost envelope so the chat doesn't imply it "knows" the
389        // post's content either.
390        if ( post_password_required( $post ) ) {
391            return null;
392        }
393
394        $context = array(
395            'id'      => $post->ID,
396            'title'   => get_the_title( $post ),
397            'url'     => get_permalink( $post ),
398            'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 120 ),
399            'author'  => get_the_author_meta( 'display_name', (int) $post->post_author ),
400            'date'    => get_the_date( 'F j, Y', $post ),
401        );
402
403        $categories = get_the_category( $post->ID );
404        if ( $categories ) {
405            $context['categories'] = wp_list_pluck( $categories, 'name' );
406        }
407
408        $tags = get_the_tags( $post->ID );
409        if ( $tags ) {
410            $context['tags'] = wp_list_pluck( $tags, 'name' );
411        }
412
413        return $context;
414    }
415
416    /**
417     * Get the version string for the CDN bundle.
418     *
419     * Attempts to read the version from the remote asset manifest.
420     * Falls back to a timestamp in dev mode, or null in production.
421     *
422     * @return string|false|null The version string, or null to omit the query param.
423     */
424    private static function get_asset_version() {
425        $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
426
427        if ( ! $skip_cache ) {
428            $cached = get_transient( READER_CHAT_ASSET_TRANSIENT );
429            if ( false !== $cached ) {
430                return $cached['version'] ?? null;
431            }
432        }
433
434        $json_path = 'widgets.wp.com/agents-manager/reader-chat.asset.json';
435
436        // Try local filesystem first (available on WordPress.com).
437        $data = self::read_local_asset_json( ABSPATH . $json_path );
438
439        // Fallback to HTTP fetch.
440        if ( false === $data ) {
441            $data = self::fetch_remote_asset_json( 'https://' . $json_path );
442        }
443
444        if ( false === $data ) {
445            // Dev mode: return a cache-busting version so the sandbox bundle loads.
446            if ( self::is_dev_mode() ) {
447                return 'dev-' . time();
448            }
449            if ( ! $skip_cache ) {
450                set_transient(
451                    READER_CHAT_ASSET_TRANSIENT,
452                    array(
453                        'version' => null,
454                    ),
455                    READER_CHAT_ASSET_FAILURE_CACHE_TTL
456                );
457            }
458            return null;
459        }
460
461        if ( ! $skip_cache ) {
462            set_transient( READER_CHAT_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
463        }
464
465        return $data['version'] ?? null;
466    }
467
468    /**
469     * Read and decode a local asset manifest JSON file.
470     *
471     * @param string $path Absolute filesystem path to the JSON file.
472     * @return array|false Decoded data or false on failure.
473     */
474    private static function read_local_asset_json( string $path ) {
475        if ( ! file_exists( $path ) ) {
476            return false;
477        }
478
479        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, not remote URL.
480        $contents = file_get_contents( $path );
481        if ( false === $contents ) {
482            return false;
483        }
484
485        return self::decode_asset_json( $contents );
486    }
487
488    /**
489     * Fetch and decode a remote asset manifest JSON file.
490     *
491     * @param string $url URL to fetch.
492     * @return array|false Decoded data or false on failure.
493     */
494    private static function fetch_remote_asset_json( string $url ) {
495        $response = wp_safe_remote_get( $url );
496        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
497            return false;
498        }
499
500        return self::decode_asset_json( wp_remote_retrieve_body( $response ) );
501    }
502
503    /**
504     * Decode a JSON asset manifest string and validate the result is an array.
505     *
506     * @param string $contents Raw JSON string.
507     * @return array|false Decoded array or false on decode failure / non-array.
508     */
509    private static function decode_asset_json( string $contents ) {
510        $data = json_decode( $contents, true );
511        if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
512            return false;
513        }
514        return $data;
515    }
516
517    /**
518     * Check whether AI features are available for this site.
519     *
520     * @return bool
521     */
522    private static function has_ai_features(): bool {
523        $host = new Host();
524
525        if ( $host->is_wpcom_simple() ) {
526            return true;
527        }
528
529        return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
530            && ! ( new Status() )->is_offline_mode()
531            && \Jetpack_AI_Settings::is_ai_enabled();
532    }
533
534    /**
535     * Check whether the current site can serve Reader Chat under its Search plan.
536     *
537     * Uses WordPress.com's local Search plan source on Simple sites. Elsewhere,
538     * uses the cached Jetpack Search plan option instead of forcing a remote
539     * refresh on public frontend requests.
540     *
541     * @return bool
542     */
543    private static function has_search_plan_access(): bool {
544        $plan_access = apply_filters( 'jetpack_reader_chat_has_search_plan_access', null );
545        if ( null !== $plan_access ) {
546            return (bool) $plan_access;
547        }
548
549        $host = new Host();
550        if ( $host->is_wpcom_simple() ) {
551            $blog_id = get_current_blog_id();
552            if ( $blog_id <= 0 ) {
553                return false;
554            }
555
556            if ( function_exists( 'require_lib' ) ) {
557                require_lib( 'jetpack-search' );
558            }
559
560            $wpcom_plan_info_class = '\Jetpack\Search\Plan_Info';
561            if ( class_exists( $wpcom_plan_info_class ) ) {
562                $plan_info = new $wpcom_plan_info_class( $blog_id );
563                return $plan_info->supports_search()
564                    && method_exists( $plan_info, 'is_free_search_plan' )
565                    && ! $plan_info->is_free_search_plan()
566                    && ! $plan_info->is_disabled_due_to_overage();
567            }
568        }
569
570        if ( ! class_exists( Plan::class ) ) {
571            return false;
572        }
573
574        $plan_info = get_option( Plan::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY );
575        if ( ! is_array( $plan_info ) ) {
576            return false;
577        }
578
579        return ! Search_Helper::is_forced_free_plan()
580            && ! empty( $plan_info['supports_search'] )
581            && ( $plan_info['effective_subscription']['product_slug'] ?? null ) !== Plan::JETPACK_SEARCH_FREE_PRODUCT_SLUG
582            && empty( $plan_info['plan_usage']['must_upgrade'] );
583    }
584
585    /**
586     * Check if the current request is from a development environment.
587     *
588     * Matches the pattern used in Jetpack_AI_Sidebar::is_dev_mode().
589     * IMPORTANT: Only use for feature gating, not authorization.
590     *
591     * @return bool
592     */
593    private static function is_dev_mode(): bool {
594        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
595        if ( ! is_string( $domain ) ) {
596            return false;
597        }
598
599        if (
600            'localhost' === $domain ||
601            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
602            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
603        ) {
604            return true;
605        }
606
607        if ( function_exists( 'wpcom_is_proxied_request' ) && wpcom_is_proxied_request() ) {
608            return true;
609        }
610
611        if (
612            ( isset( $_SERVER['A8C_PROXIED_REQUEST'] ) && (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) ) ) ||
613            ( defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST )
614        ) {
615            return true;
616        }
617
618        if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST && defined( 'ATOMIC_CLIENT_ID' ) ) {
619            switch ( ATOMIC_CLIENT_ID ) {
620                case 1:
621                case 2:
622                case 3: // Pressable
623                case 32:
624                case 118: // Commerce garden client (ciab)
625                    return true;
626            }
627        }
628
629        return false;
630    }
631}