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