Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.36% covered (warning)
86.36%
76 / 88
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Settings
89.41% covered (warning)
89.41%
76 / 85
92.86% covered (success)
92.86%
13 / 14
44.09
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 register_settings
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 add_sync_options_whitelist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 apply_master_gates
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 should_enforce_ai_controls
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 is_ai_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 host_allows_ai
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_master_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 site_is_connected
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 user_is_connected
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_master_forced_off_route
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 set_master_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_feature_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 is_ai_seo_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Jetpack AI feature settings.
4 *
5 * Central registry for the Jetpack AI master switch and per-feature toggles,
6 * implementing the layered AI gate contract:
7 *
8 *   1. the host allows AI            — WP_AI_SUPPORT, via wp_supports_ai()
9 *   2. the plan includes AI          — connection + plan checks (owned by each feature)
10 *   3. AI is on for the whole site   — the jetpack_ai_enabled option (master switch)
11 *   4. the feature's own switch      — per-feature options surfaced on the AI settings page
12 *
13 * Gates 1 and 3 are enforced by is_ai_enabled(), which plugin load points call
14 * instead of applying `jetpack_ai_enabled` directly: the gates AND in after the
15 * filter chain, so no later-priority callback can override them. The gates also
16 * ride the filter itself for package consumers that cannot reference this class.
17 * Gate 4 options are registered here and consulted at each feature's registration
18 * or enqueue point — a disabled feature must stop loading, not just hide.
19 *
20 * @package automattic/jetpack
21 */
22
23use Automattic\Jetpack\Connection\Manager;
24use Automattic\Jetpack\Modules;
25use Automattic\Jetpack\Status;
26use Automattic\Jetpack\Status\Host;
27
28if ( ! defined( 'ABSPATH' ) ) {
29    exit( 0 );
30}
31
32// All consumers require this canonical file once. A class_exists() guard here
33// would be true on the first load because PHP registers unconditional classes
34// before executing the file, returning before the self-initialization below.
35
36/**
37 * Registers the Jetpack AI master switch and per-feature toggle options, and
38 * enforces the host (WP_AI_SUPPORT) and master gates on the AI filters.
39 */
40class Jetpack_AI_Settings {
41
42    /**
43     * Master switch option. Named after the pre-existing `jetpack_ai_enabled`
44     * filter it backs, following the `reader_chat` option/filter precedent.
45     *
46     * @var string
47     */
48    const MASTER_OPTION = 'jetpack_ai_enabled';
49
50    /**
51     * Slug of the `ai` module that acts as the site-wide master switch off
52     * WordPress.com Simple (self-hosted and Atomic), where modules run.
53     *
54     * @var string
55     */
56    const AI_MODULE = 'ai';
57
58    /**
59     * The `jetpack_ai_enabled` route custom code can take to hold AI off, as
60     * reported by {@see self::get_master_forced_off_route()}. Each route names a
61     * different hook, so each needs its own documentation link.
62     *
63     * @var string
64     */
65    const FORCED_OFF_ROUTE_FILTER = 'filter';
66
67    /**
68     * The module-filter route; see {@see self::FORCED_OFF_ROUTE_FILTER}.
69     *
70     * @var string
71     */
72    const FORCED_OFF_ROUTE_MODULES = 'modules';
73
74    /**
75     * The filter route on VIP, which documents this filter as its own supported
76     * off switch and so owns the page to send the reader to.
77     *
78     * @var string
79     */
80    const FORCED_OFF_ROUTE_FILTER_VIP = 'filter-vip';
81
82    /**
83     * Feature key => option name for every toggle on the AI settings page.
84     *
85     * `ai_search` reuses an option owned by the Search surface; the rest are
86     * registered by this class. The automatic-generation option is deliberately
87     * absent: the Traffic page and the SEO dashboard own it.
88     *
89     * @var array
90     */
91    const FEATURE_OPTIONS = array(
92        'writing_assistant' => 'jetpack_ai_writing_assistant_enabled',
93        'image_editor'      => 'jetpack_ai_image_editor_enabled',
94        'feature_clip'      => 'jetpack_ai_feature_clip_enabled',
95        'ai_seo'            => 'jetpack_ai_seo_enabled',
96        'ai_search'         => 'jetpack_search_ai_answers_enabled',
97    );
98
99    /**
100     * Option defaults. The reused Search option keeps its established opt-in
101     * default; the new per-feature toggles default to on.
102     *
103     * @var array
104     */
105    const FEATURE_DEFAULTS = array(
106        'writing_assistant' => true,
107        'image_editor'      => true,
108        'feature_clip'      => true,
109        'ai_seo'            => true,
110        'ai_search'         => false,
111    );
112
113    /**
114     * Feature keys whose options this class registers and syncs (the reused
115     * Search option is registered by its owning surface).
116     *
117     * @var array
118     */
119    const OWNED_FEATURES = array( 'writing_assistant', 'image_editor', 'feature_clip', 'ai_seo' );
120
121    /**
122     * Whether init() has already run.
123     *
124     * @var bool
125     */
126    private static $initialized = false;
127
128    /**
129     * Whether apply_master_gates() should step aside; see
130     * {@see self::get_master_forced_off_route()}.
131     *
132     * @var bool
133     */
134    private static $probing_third_party = false;
135
136    /**
137     * Hook everything up. Must run on every request (front-end, editor, REST):
138     * the filters attached here gate feature loading.
139     *
140     * @return void
141     */
142    public static function init() {
143        if ( self::$initialized ) {
144            return;
145        }
146        self::$initialized = true;
147
148        add_action( 'init', array( __CLASS__, 'register_settings' ) );
149        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) );
150
151        // Plugin call sites use is_ai_enabled(), which applies gates 1 (host) and
152        // 3 (master) after the filter chain. This in-chain registration stays for
153        // the package consumers that cannot reference this plugin class
154        // (external-media, my-jetpack): there the gates keep their pre-helper,
155        // priority-10 behavior.
156        add_filter( 'jetpack_ai_enabled', array( __CLASS__, 'apply_master_gates' ) );
157
158        // AI surfaces that do not flow through jetpack_ai_enabled.
159        add_filter( 'jetpack_search_ai_answers_enabled', array( __CLASS__, 'apply_master_gates' ) );
160        add_filter( 'jetpack_ai_sidebar_enabled', array( __CLASS__, 'apply_master_gates' ) );
161        add_filter( 'jetpack_ai_seo_enabled', array( __CLASS__, 'apply_master_gates' ) );
162    }
163
164    /**
165     * Register the master switch and the per-feature options this class owns.
166     *
167     * @return void
168     */
169    public static function register_settings() {
170        $show_in_rest = ! ( new Host() )->is_wpcom_simple();
171
172        $options = array(
173            self::MASTER_OPTION                        => __( 'Whether Jetpack AI is enabled on this site.', 'jetpack' ),
174            self::FEATURE_OPTIONS['writing_assistant'] => __( 'Whether the Jetpack AI writing assistant is enabled.', 'jetpack' ),
175            self::FEATURE_OPTIONS['image_editor']      => __( 'Whether the Jetpack AI image editor is enabled.', 'jetpack' ),
176            self::FEATURE_OPTIONS['feature_clip']      => __( 'Whether Jetpack AI video clip generation is enabled.', 'jetpack' ),
177            self::FEATURE_OPTIONS['ai_seo']            => __( 'Whether the Jetpack AI SEO features are enabled.', 'jetpack' ),
178        );
179
180        // These settings do not belong to Settings > General. A separate group
181        // prevents options.php from clearing values whose fields are absent from
182        // the General form.
183        foreach ( $options as $option => $description ) {
184            register_setting(
185                'jetpack_ai',
186                $option,
187                array(
188                    'type'              => 'boolean',
189                    'description'       => $description,
190                    'sanitize_callback' => 'rest_sanitize_boolean',
191                    // The master option is never exposed over core settings REST:
192                    // off-Simple the `ai` module is the master and the option only
193                    // holds the legacy pre-module opt-out (a core-REST write would
194                    // clobber it without touching the real master); on Simple the
195                    // dedicated feature-settings endpoint is the writable surface.
196                    'show_in_rest'      => self::MASTER_OPTION === $option ? false : $show_in_rest,
197                    'default'           => true,
198                )
199            );
200        }
201    }
202
203    /**
204     * Add the per-feature AI options to Jetpack Sync's option whitelist.
205     *
206     * Atomic and self-hosted sites write these locally; syncing them lets
207     * WordPress.com (Calypso, the multi-site dashboard) read toggle state and
208     * is the prerequisite for mirroring the dashboard AI toggle later.
209     *
210     * The master switch is deliberately absent: off-Simple the `ai` module is
211     * the master, and module state already reaches WordPress.com through the
212     * synced `active_modules` callable — syncing the option as well would add
213     * a second, driftable source of truth for the same bit.
214     *
215     * @param array $options Option names allowed to sync.
216     * @return array Updated option names.
217     */
218    public static function add_sync_options_whitelist( $options ) {
219        $options = (array) $options;
220        foreach ( self::OWNED_FEATURES as $feature ) {
221            $options[] = self::FEATURE_OPTIONS[ $feature ];
222        }
223        return array_values( array_unique( $options ) );
224    }
225
226    /**
227     * Fold the host (gate 1) and master switch (gate 3) into an AI enabled filter.
228     *
229     * Restrictive-only on purpose: `jetpack_ai_enabled` is applied with different
230     * defaults at different call sites (Jetpack_AI_Helper passes false on plain
231     * self-hosted sites; the editor extension hub passes true), so this callback
232     * may only ever turn a yes into a no — returning the option value directly
233     * would flip self-hosted defaults to enabled.
234     *
235     * @param bool $enabled The value the call site computed so far.
236     * @return bool
237     */
238    public static function apply_master_gates( $enabled ) {
239        // Stand aside while get_master_forced_off_route() asks the chain what
240        // everyone else says; our own verdict would drown theirs out.
241        if ( self::$probing_third_party ) {
242            return (bool) $enabled;
243        }
244
245        return (bool) $enabled
246            && self::host_allows_ai()
247            && ( ! self::should_enforce_ai_controls() || self::is_master_enabled() );
248    }
249
250    /**
251     * Whether the AI controls — the master switch and the toggles this class owns
252     * — take effect here. Simple keeps its existing option contract, self-hosted
253     * sites use the Jetpack controls, and Atomic remains limited to internal testing.
254     *
255     * @return bool
256     */
257    private static function should_enforce_ai_controls() {
258        $host = new Host();
259        if ( $host->is_wpcom_simple() ) {
260            return true;
261        }
262
263        return ! $host->is_woa_site()
264            || ( function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment() );
265    }
266
267    /**
268     * Whether Jetpack AI is enabled on this site, with the host (gate 1) and
269     * master switch (gate 3) as final, non-overridable checks.
270     *
271     * Runs the `jetpack_ai_enabled` filter with the call site's default — the
272     * chain may still enable or disable as before — then ANDs the host and
273     * master gates after it, so no late-priority callback can turn AI back on
274     * once either gate says no. Plugin call sites use this helper; the filter
275     * registration in init() stays for the package consumers that cannot
276     * reference this class.
277     *
278     * @since 16.2
279     *
280     * @param bool $default The call site's computed default. Defaults differ
281     *                      between call sites — see apply_master_gates().
282     * @return bool
283     */
284    public static function is_ai_enabled( $default = true ) {
285        /**
286         * Filter whether the AI features are enabled in the Jetpack plugin.
287         *
288         * @since 11.8
289         *
290         * @param bool $default Are AI features enabled? The default varies by call site.
291         */
292        $enabled = (bool) apply_filters( 'jetpack_ai_enabled', $default );
293
294        return self::apply_master_gates( $enabled );
295    }
296
297    /**
298     * Gate 1: whether the host allows AI at all.
299     *
300     * Defers to core's wp_supports_ai(), which is backed by the WP_AI_SUPPORT
301     * constant and its own filter. This is a server-owner decision: when it is
302     * off, no AI settings should be shown and no upgrade should ever be offered.
303     *
304     * @return bool
305     */
306    public static function host_allows_ai() {
307        return wp_supports_ai();
308    }
309
310    /**
311     * Gate 3: whether the site-wide AI master switch is on.
312     *
313     * The master lives in a different place depending on the platform. On
314     * WordPress.com Simple no Jetpack modules run, so the `jetpack_ai_enabled`
315     * option is the master. Everywhere else (self-hosted and Atomic) the `ai`
316     * module is the real master switch, toggled through the standard Jetpack
317     * module machinery; there the option only carries the legacy pre-module
318     * value the one-time opt-out migration reads, and is never written again.
319     *
320     * @return bool
321     */
322    public static function is_master_enabled() {
323        if ( ( new Host() )->is_wpcom_simple() ) {
324            return (bool) get_option( self::MASTER_OPTION, true );
325        }
326
327        return ( new Modules() )->is_active( self::AI_MODULE );
328    }
329
330    /**
331     * Whether the site's WordPress.com connection can carry AI. Offline mode
332     * counts as disconnected even while the site holds its tokens, and Simple
333     * sites are always connected.
334     *
335     * @return bool
336     */
337    public static function site_is_connected() {
338        return ( new Host() )->is_wpcom_simple()
339            || ( ( new Manager( 'jetpack' ) )->has_connected_owner()
340                && ! ( new Status() )->is_offline_mode() );
341    }
342
343    /**
344     * Whether the current user's own account is connected. Surfaces that proxy
345     * as the requesting user need this on top of {@see self::site_is_connected()}.
346     *
347     * @return bool
348     */
349    public static function user_is_connected() {
350        return ( new Host() )->is_wpcom_simple()
351            || ( new Manager( 'jetpack' ) )->is_user_connected();
352    }
353
354    /**
355     * Which hook custom code used to hold AI off, so the notice can link to the
356     * matching documentation. Always empty on WordPress.com Simple, which runs
357     * no modules.
358     *
359     * @return string One of the FORCED_OFF_ROUTE_* constants, or '' when nothing
360     *                holds AI off.
361     */
362    public static function get_master_forced_off_route() {
363        $host = new Host();
364
365        if ( $host->is_wpcom_simple() ) {
366            return '';
367        }
368
369        // Ask the chain with our own gates stood down, so a deactivated module
370        // cannot mask a filter that would keep AI off however the module is set.
371        $third_party_off           = false;
372        self::$probing_third_party = true;
373        try {
374            $third_party_off = ! apply_filters( 'jetpack_ai_enabled', true );
375        } finally {
376            self::$probing_third_party = false;
377        }
378
379        if ( $third_party_off ) {
380            return $host->is_vip_site()
381                ? self::FORCED_OFF_ROUTE_FILTER_VIP
382                : self::FORCED_OFF_ROUTE_FILTER;
383        }
384
385        if ( self::is_master_enabled() ) {
386            return '';
387        }
388
389        // Removed from the available list by `jetpack_get_available_modules`.
390        if ( ! in_array( self::AI_MODULE, ( new Modules() )->get_available(), true ) ) {
391            return self::FORCED_OFF_ROUTE_MODULES;
392        }
393
394        // Forced off through `option_jetpack_active_modules` or `jetpack_active_modules`.
395        $overridden = class_exists( 'Jetpack_Modules_Overrides' )
396            && 'inactive' === Jetpack_Modules_Overrides::instance()->get_module_override( self::AI_MODULE );
397
398        return $overridden ? self::FORCED_OFF_ROUTE_MODULES : '';
399    }
400
401    /**
402     * Set the site-wide AI master switch, writing to whichever store backs it on
403     * this platform (see {@see self::is_master_enabled()}).
404     *
405     * On WordPress.com Simple the `jetpack_ai_enabled` option is the master, so
406     * we update it. Off-Simple the `ai` module is the master, so we activate or
407     * deactivate it. The no-exit / no-redirect arguments are passed to
408     * `Modules::update_status()` so this is safe to call outside a request that
409     * expects to terminate (REST handlers, migrations, CLI).
410     *
411     * @param bool $enabled Whether AI should be enabled site-wide.
412     * @return void
413     */
414    public static function set_master_enabled( bool $enabled ) {
415        if ( ( new Host() )->is_wpcom_simple() ) {
416            update_option( self::MASTER_OPTION, $enabled );
417            return;
418        }
419
420        // The module alone is the master off-Simple. The option is deliberately NOT
421        // written here: WordPress.com derives the master state from the synced
422        // `active_modules` callable, and the stored option must keep its legacy
423        // pre-module value so Jetpack::reconcile_ai_master_optout() can read an
424        // explicit opt-out on sites that upgrade later.
425        ( new Modules() )->update_status( self::AI_MODULE, $enabled, false, false );
426    }
427
428    /**
429     * Gate 4: whether an individual feature's switch is on.
430     *
431     * Checks only the feature's own toggle — callers remain responsible for the
432     * outer gates (most already consult the jetpack_ai_enabled filter, which
433     * carries host + master). Only the matching option is read: a code-level
434     * override belongs on the option itself, through core's own option filters.
435     *
436     * Not {@see self::is_ai_seo_enabled()}, which is this check for the `ai_seo`
437     * key plus its filter and the site-wide gates. Use that one at load points.
438     *
439     * @param string $feature Feature key (see FEATURE_OPTIONS).
440     * @return bool False for unknown features.
441     */
442    public static function is_feature_enabled( $feature ) {
443        if ( ! isset( self::FEATURE_OPTIONS[ $feature ] ) ) {
444            return false;
445        }
446
447        // The toggles this class owns stay on wherever they do not apply: Simple keeps
448        // the existing wp.com settings contract, while Atomic keeps them hidden.
449        // The reused Search option has its own settings surface, so it always honors
450        // its stored value.
451        if ( in_array( $feature, self::OWNED_FEATURES, true )
452            && ( ( new Host() )->is_wpcom_simple() || ! self::should_enforce_ai_controls() ) ) {
453            return true;
454        }
455
456        $option = self::FEATURE_OPTIONS[ $feature ];
457
458        return (bool) get_option( $option, self::FEATURE_DEFAULTS[ $feature ] );
459    }
460
461    /**
462     * Whether AI SEO is enabled after its feature filter and the site-wide AI checks.
463     *
464     * @since 16.2
465     *
466     * @return bool
467     */
468    public static function is_ai_seo_enabled() {
469        /**
470         * Filter whether the Jetpack AI SEO feature is enabled.
471         *
472         * @since 16.2
473         *
474         * @param bool $enabled Whether the SEO feature toggle is on.
475         */
476        $enabled = (bool) apply_filters( 'jetpack_ai_seo_enabled', self::is_feature_enabled( 'ai_seo' ) );
477
478        return $enabled && self::is_ai_enabled();
479    }
480}
481
482// Self-initialize on load. The consuming AI extension files require this file
483// directly (__DIR__-relative) because on WordPress.com Simple the plugin's
484// extension files load through wpcom's own loader and load-jetpack.php never
485// runs. This keeps filter registration identical in both bootstrap paths.
486Jetpack_AI_Settings::init();