option name for every toggle on the AI settings page. * * `ai_search` reuses an option owned by the Search surface; the rest are * registered by this class. The automatic-generation option is deliberately * absent: the Traffic page and the SEO dashboard own it. * * @var array */ const FEATURE_OPTIONS = array( 'writing_assistant' => 'jetpack_ai_writing_assistant_enabled', 'image_editor' => 'jetpack_ai_image_editor_enabled', 'feature_clip' => 'jetpack_ai_feature_clip_enabled', 'ai_seo' => 'jetpack_ai_seo_enabled', 'ai_search' => 'jetpack_search_ai_answers_enabled', ); /** * Option defaults. The reused Search option keeps its established opt-in * default; the new per-feature toggles default to on. * * @var array */ const FEATURE_DEFAULTS = array( 'writing_assistant' => true, 'image_editor' => true, 'feature_clip' => true, 'ai_seo' => true, 'ai_search' => false, ); /** * Feature keys whose options this class registers and syncs (the reused * Search option is registered by its owning surface). * * @var array */ const OWNED_FEATURES = array( 'writing_assistant', 'image_editor', 'feature_clip', 'ai_seo' ); /** * Whether init() has already run. * * @var bool */ private static $initialized = false; /** * Whether apply_master_gates() should step aside; see * {@see self::get_master_forced_off_route()}. * * @var bool */ private static $probing_third_party = false; /** * Hook everything up. Must run on every request (front-end, editor, REST): * the filters attached here gate feature loading. * * @return void */ public static function init() { if ( self::$initialized ) { return; } self::$initialized = true; add_action( 'init', array( __CLASS__, 'register_settings' ) ); add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) ); // Plugin call sites use is_ai_enabled(), which applies gates 1 (host) and // 3 (master) after the filter chain. This in-chain registration stays for // the package consumers that cannot reference this plugin class // (external-media, my-jetpack): there the gates keep their pre-helper, // priority-10 behavior. add_filter( 'jetpack_ai_enabled', array( __CLASS__, 'apply_master_gates' ) ); // AI surfaces that do not flow through jetpack_ai_enabled. add_filter( 'jetpack_search_ai_answers_enabled', array( __CLASS__, 'apply_master_gates' ) ); add_filter( 'jetpack_ai_sidebar_enabled', array( __CLASS__, 'apply_master_gates' ) ); add_filter( 'jetpack_ai_seo_enabled', array( __CLASS__, 'apply_master_gates' ) ); } /** * Register the master switch and the per-feature options this class owns. * * @return void */ public static function register_settings() { $show_in_rest = ! ( new Host() )->is_wpcom_simple(); $options = array( self::MASTER_OPTION => __( 'Whether Jetpack AI is enabled on this site.', 'jetpack' ), self::FEATURE_OPTIONS['writing_assistant'] => __( 'Whether the Jetpack AI writing assistant is enabled.', 'jetpack' ), self::FEATURE_OPTIONS['image_editor'] => __( 'Whether the Jetpack AI image editor is enabled.', 'jetpack' ), self::FEATURE_OPTIONS['feature_clip'] => __( 'Whether Jetpack AI video clip generation is enabled.', 'jetpack' ), self::FEATURE_OPTIONS['ai_seo'] => __( 'Whether the Jetpack AI SEO features are enabled.', 'jetpack' ), ); // These settings do not belong to Settings > General. A separate group // prevents options.php from clearing values whose fields are absent from // the General form. foreach ( $options as $option => $description ) { register_setting( 'jetpack_ai', $option, array( 'type' => 'boolean', 'description' => $description, 'sanitize_callback' => 'rest_sanitize_boolean', // The master option is never exposed over core settings REST: // off-Simple the `ai` module is the master and the option only // holds the legacy pre-module opt-out (a core-REST write would // clobber it without touching the real master); on Simple the // dedicated feature-settings endpoint is the writable surface. 'show_in_rest' => self::MASTER_OPTION === $option ? false : $show_in_rest, 'default' => true, ) ); } } /** * Add the per-feature AI options to Jetpack Sync's option whitelist. * * Atomic and self-hosted sites write these locally; syncing them lets * WordPress.com (Calypso, the multi-site dashboard) read toggle state and * is the prerequisite for mirroring the dashboard AI toggle later. * * The master switch is deliberately absent: off-Simple the `ai` module is * the master, and module state already reaches WordPress.com through the * synced `active_modules` callable — syncing the option as well would add * a second, driftable source of truth for the same bit. * * @param array $options Option names allowed to sync. * @return array Updated option names. */ public static function add_sync_options_whitelist( $options ) { $options = (array) $options; foreach ( self::OWNED_FEATURES as $feature ) { $options[] = self::FEATURE_OPTIONS[ $feature ]; } return array_values( array_unique( $options ) ); } /** * Fold the host (gate 1) and master switch (gate 3) into an AI enabled filter. * * Restrictive-only on purpose: `jetpack_ai_enabled` is applied with different * defaults at different call sites (Jetpack_AI_Helper passes false on plain * self-hosted sites; the editor extension hub passes true), so this callback * may only ever turn a yes into a no — returning the option value directly * would flip self-hosted defaults to enabled. * * @param bool $enabled The value the call site computed so far. * @return bool */ public static function apply_master_gates( $enabled ) { // Stand aside while get_master_forced_off_route() asks the chain what // everyone else says; our own verdict would drown theirs out. if ( self::$probing_third_party ) { return (bool) $enabled; } return (bool) $enabled && self::host_allows_ai() && ( ! self::should_enforce_ai_controls() || self::is_master_enabled() ); } /** * Whether the AI controls — the master switch and the toggles this class owns * — take effect here. Simple keeps its existing option contract, self-hosted * sites use the Jetpack controls, and Atomic remains limited to internal testing. * * @return bool */ private static function should_enforce_ai_controls() { $host = new Host(); if ( $host->is_wpcom_simple() ) { return true; } return ! $host->is_woa_site() || ( function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment() ); } /** * Whether Jetpack AI is enabled on this site, with the host (gate 1) and * master switch (gate 3) as final, non-overridable checks. * * Runs the `jetpack_ai_enabled` filter with the call site's default — the * chain may still enable or disable as before — then ANDs the host and * master gates after it, so no late-priority callback can turn AI back on * once either gate says no. Plugin call sites use this helper; the filter * registration in init() stays for the package consumers that cannot * reference this class. * * @since 16.2 * * @param bool $default The call site's computed default. Defaults differ * between call sites — see apply_master_gates(). * @return bool */ public static function is_ai_enabled( $default = true ) { /** * Filter whether the AI features are enabled in the Jetpack plugin. * * @since 11.8 * * @param bool $default Are AI features enabled? The default varies by call site. */ $enabled = (bool) apply_filters( 'jetpack_ai_enabled', $default ); return self::apply_master_gates( $enabled ); } /** * Gate 1: whether the host allows AI at all. * * Defers to core's wp_supports_ai(), which is backed by the WP_AI_SUPPORT * constant and its own filter. This is a server-owner decision: when it is * off, no AI settings should be shown and no upgrade should ever be offered. * * @return bool */ public static function host_allows_ai() { return wp_supports_ai(); } /** * Gate 3: whether the site-wide AI master switch is on. * * The master lives in a different place depending on the platform. On * WordPress.com Simple no Jetpack modules run, so the `jetpack_ai_enabled` * option is the master. Everywhere else (self-hosted and Atomic) the `ai` * module is the real master switch, toggled through the standard Jetpack * module machinery; there the option only carries the legacy pre-module * value the one-time opt-out migration reads, and is never written again. * * @return bool */ public static function is_master_enabled() { if ( ( new Host() )->is_wpcom_simple() ) { return (bool) get_option( self::MASTER_OPTION, true ); } return ( new Modules() )->is_active( self::AI_MODULE ); } /** * Whether the site's WordPress.com connection can carry AI. Offline mode * counts as disconnected even while the site holds its tokens, and Simple * sites are always connected. * * @return bool */ public static function site_is_connected() { return ( new Host() )->is_wpcom_simple() || ( ( new Manager( 'jetpack' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode() ); } /** * Whether the current user's own account is connected. Surfaces that proxy * as the requesting user need this on top of {@see self::site_is_connected()}. * * @return bool */ public static function user_is_connected() { return ( new Host() )->is_wpcom_simple() || ( new Manager( 'jetpack' ) )->is_user_connected(); } /** * Which hook custom code used to hold AI off, so the notice can link to the * matching documentation. Always empty on WordPress.com Simple, which runs * no modules. * * @return string One of the FORCED_OFF_ROUTE_* constants, or '' when nothing * holds AI off. */ public static function get_master_forced_off_route() { $host = new Host(); if ( $host->is_wpcom_simple() ) { return ''; } // Ask the chain with our own gates stood down, so a deactivated module // cannot mask a filter that would keep AI off however the module is set. $third_party_off = false; self::$probing_third_party = true; try { $third_party_off = ! apply_filters( 'jetpack_ai_enabled', true ); } finally { self::$probing_third_party = false; } if ( $third_party_off ) { return $host->is_vip_site() ? self::FORCED_OFF_ROUTE_FILTER_VIP : self::FORCED_OFF_ROUTE_FILTER; } if ( self::is_master_enabled() ) { return ''; } // Removed from the available list by `jetpack_get_available_modules`. if ( ! in_array( self::AI_MODULE, ( new Modules() )->get_available(), true ) ) { return self::FORCED_OFF_ROUTE_MODULES; } // Forced off through `option_jetpack_active_modules` or `jetpack_active_modules`. $overridden = class_exists( 'Jetpack_Modules_Overrides' ) && 'inactive' === Jetpack_Modules_Overrides::instance()->get_module_override( self::AI_MODULE ); return $overridden ? self::FORCED_OFF_ROUTE_MODULES : ''; } /** * Set the site-wide AI master switch, writing to whichever store backs it on * this platform (see {@see self::is_master_enabled()}). * * On WordPress.com Simple the `jetpack_ai_enabled` option is the master, so * we update it. Off-Simple the `ai` module is the master, so we activate or * deactivate it. The no-exit / no-redirect arguments are passed to * `Modules::update_status()` so this is safe to call outside a request that * expects to terminate (REST handlers, migrations, CLI). * * @param bool $enabled Whether AI should be enabled site-wide. * @return void */ public static function set_master_enabled( bool $enabled ) { if ( ( new Host() )->is_wpcom_simple() ) { update_option( self::MASTER_OPTION, $enabled ); return; } // The module alone is the master off-Simple. The option is deliberately NOT // written here: WordPress.com derives the master state from the synced // `active_modules` callable, and the stored option must keep its legacy // pre-module value so Jetpack::reconcile_ai_master_optout() can read an // explicit opt-out on sites that upgrade later. ( new Modules() )->update_status( self::AI_MODULE, $enabled, false, false ); } /** * Gate 4: whether an individual feature's switch is on. * * Checks only the feature's own toggle — callers remain responsible for the * outer gates (most already consult the jetpack_ai_enabled filter, which * carries host + master). Only the matching option is read: a code-level * override belongs on the option itself, through core's own option filters. * * Not {@see self::is_ai_seo_enabled()}, which is this check for the `ai_seo` * key plus its filter and the site-wide gates. Use that one at load points. * * @param string $feature Feature key (see FEATURE_OPTIONS). * @return bool False for unknown features. */ public static function is_feature_enabled( $feature ) { if ( ! isset( self::FEATURE_OPTIONS[ $feature ] ) ) { return false; } // The toggles this class owns stay on wherever they do not apply: Simple keeps // the existing wp.com settings contract, while Atomic keeps them hidden. // The reused Search option has its own settings surface, so it always honors // its stored value. if ( in_array( $feature, self::OWNED_FEATURES, true ) && ( ( new Host() )->is_wpcom_simple() || ! self::should_enforce_ai_controls() ) ) { return true; } $option = self::FEATURE_OPTIONS[ $feature ]; return (bool) get_option( $option, self::FEATURE_DEFAULTS[ $feature ] ); } /** * Whether the AI SEO feature (metadata generation, manual and automatic) * is effectively enabled: its own toggle (gate 4) through the filter, with * the host and master gates ANDed after the chain so no late-priority * callback can turn the feature back on — same finality as is_ai_enabled(). * * Not {@see self::is_feature_enabled()} with `ai_seo`, which is the stored * toggle alone. This is the one load points and payloads should read. * * @since 16.2 * * @return bool */ public static function is_ai_seo_enabled() { /** * Filter whether the Jetpack AI SEO feature is enabled. * * @since 16.2 * * @param bool $enabled Whether the SEO feature toggle is on. */ $enabled = (bool) apply_filters( 'jetpack_ai_seo_enabled', self::is_feature_enabled( 'ai_seo' ) ); return self::apply_master_gates( $enabled ); } } // Self-initialize on load. The consuming AI extension files require this file // directly (__DIR__-relative) because on WordPress.com Simple the plugin's // extension files load through wpcom's own loader and load-jetpack.php never // runs. This keeps filter registration identical in both bootstrap paths. Jetpack_AI_Settings::init();