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