| @@ -19,9 +19,11 @@ | ||
| 19 | 19 | * |
| 20 | 20 | * @package automattic/jetpack |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | +use Automattic\Jetpack\Connection\Manager; | |
| 23 | 24 | use Automattic\Jetpack\Modules; |
| 25 | +use Automattic\Jetpack\Status; | |
| 24 | 26 | use Automattic\Jetpack\Status\Host; |
| 25 | 27 | |
| 26 | 28 | if ( ! defined( 'ABSPATH' ) ) { |
| 27 | 29 | exit( 0 ); |
| @@ -53,8 +55,32 @@ | ||
| 53 | 55 | */ |
| 54 | 56 | const AI_MODULE = 'ai'; |
| 55 | 57 | |
| 56 | 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 | + /** | |
| 57 | 83 | * Feature key => option name for every toggle on the AI settings page. |
| 58 | 84 | * |
| 59 | 85 | * `ai_search` reuses an option owned by the Search surface; the rest are |
| 60 | 86 | * registered by this class. The automatic-generation option is deliberately |
| @@ -99,8 +125,16 @@ | ||
| 99 | 125 | */ |
| 100 | 126 | private static $initialized = false; |
| 101 | 127 | |
| 102 | 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 | + /** | |
| 103 | 137 | * Hook everything up. Must run on every request (front-end, editor, REST): |
| 104 | 138 | * the filters attached here gate feature loading. |
| 105 | 139 | * |
| 106 | 140 | * @return void |
| @@ -201,8 +235,14 @@ | ||
| 201 | 235 | * @param bool $enabled The value the call site computed so far. |
| 202 | 236 | * @return bool |
| 203 | 237 | */ |
| 204 | 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 | + | |
| 205 | 245 | return (bool) $enabled |
| 206 | 246 | && self::host_allows_ai() |
| 207 | 247 | && ( ! self::should_enforce_ai_controls() || self::is_master_enabled() ); |
| 208 | 248 | } |
| @@ -287,8 +327,79 @@ | ||
| 287 | 327 | return ( new Modules() )->is_active( self::AI_MODULE ); |
| 288 | 328 | } |
| 289 | 329 | |
| 290 | 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 | + /** | |
| 291 | 402 | * Set the site-wide AI master switch, writing to whichever store backs it on |
| 292 | 403 | * this platform (see {@see self::is_master_enabled()}). |
| 293 | 404 | * |
| 294 | 405 | * On WordPress.com Simple the `jetpack_ai_enabled` option is the master, so |
| @@ -347,16 +458,10 @@ | ||
| 347 | 458 | return (bool) get_option( $option, self::FEATURE_DEFAULTS[ $feature ] ); |
| 348 | 459 | } |
| 349 | 460 | |
| 350 | 461 | /** |
| 351 | - * Whether the AI SEO feature (metadata generation, manual and automatic) | |
| 352 | - * is effectively enabled: its own toggle (gate 4) through the filter, with | |
| 353 | - * the host and master gates ANDed after the chain so no late-priority | |
| 354 | - * callback can turn the feature back on — same finality as is_ai_enabled(). | |
| 462 | + * Whether AI SEO is enabled after its feature filter and the site-wide AI checks. | |
| 355 | 463 | * |
| 356 | - * Not {@see self::is_feature_enabled()} with `ai_seo`, which is the stored | |
| 357 | - * toggle alone. This is the one load points and payloads should read. | |
| 358 | - * | |
| 359 | 464 | * @since 16.2 |
| 360 | 465 | * |
| 361 | 466 | * @return bool |
| 362 | 467 | */ |
| @@ -369,9 +474,9 @@ | ||
| 369 | 474 | * @param bool $enabled Whether the SEO feature toggle is on. |
| 370 | 475 | */ |
| 371 | 476 | $enabled = (bool) apply_filters( 'jetpack_ai_seo_enabled', self::is_feature_enabled( 'ai_seo' ) ); |
| 372 | 477 | |
| 373 | - return self::apply_master_gates( $enabled ); | |
| 478 | + return $enabled && self::is_ai_enabled(); | |
| 374 | 479 | } |
| 375 | 480 | } |
| 376 | 481 | |
| 377 | 482 | // Self-initialize on load. The consuming AI extension files require this file |