PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | _inc/lib/class-jetpack-ai-settings.php +486 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,486 @@
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 +
23 +use Automattic\Jetpack\Connection\Manager;
24 +use Automattic\Jetpack\Modules;
25 +use Automattic\Jetpack\Status;
26 +use Automattic\Jetpack\Status\Host;
27 +
28 +if ( ! 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 + */
40 +class 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.
486 +Jetpack_AI_Settings::init();