PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 13.7.2 13.8.3 All 506 releases
jetpack / _inc / lib / class-jetpack-ai-settings.php

class-jetpack-ai-settings.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.5, at _inc/lib/class-jetpack-ai-settings.php

487 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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();
487