setOptions(); add_action( 'admin_init', [ $this, 'scheduleGetAccessToken' ] ); add_action( $this->getAccessTokenAction, [ $this, 'getAccessToken' ] ); $this->assistant = new Assistant(); $this->image = new Image(); $this->bulkActions = new BulkActions(); } /** * Returns whether AI features are disabled. * * @since 4.9.9 * * @return bool Whether AI features are disabled. */ public function isDisabled() { static $cached = null; if ( null !== $cached ) { return $cached; } if ( (bool) apply_filters( 'aioseo_ai_disabled', false ) ) { $cached = true; return true; } // Backwards compatibility: each legacy hook used to enable a specific AI UI extension (default `true`). // If an integration filtered any of them to `false`, honor that intent by disabling AI globally. $legacyEnableHooks = [ 'aioseo_ai_assistant_block_enabled', 'aioseo_ai_assistant_extend_block_editor_inserter_button', 'aioseo_ai_assistant_extend_paragraph_placeholder', 'aioseo_ai_image_generator_extend_image_block_toolbar', 'aioseo_ai_image_generator_extend_image_block_placeholder', 'aioseo_ai_image_generator_extend_featured_image_button' ]; // In REST/AJAX/CRON the deprecation notice HTML would corrupt the JSON body under WP_DEBUG_DISPLAY, // so skip the notice there but still honor the legacy filter via apply_filters(). $canEmitDeprecation = ! aioseo()->helpers->isAjaxCronRestRequest(); foreach ( $legacyEnableHooks as $hook ) { if ( ! has_filter( $hook ) ) { continue; } $value = $canEmitDeprecation ? apply_filters_deprecated( $hook, [ true ], '4.9.9', 'aioseo_ai_disabled' ) : apply_filters( $hook, true ); // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound if ( ! (bool) $value ) { $cached = true; return true; } } $cached = false; return false; } /** * Schedules the initial access token fetch action if no access token is set. * * @since 4.9.1 * * @return void */ public function scheduleGetAccessToken() { if ( aioseo()->sensitiveOptions->hasValue( 'aiAccessToken' ) ) { return; } if ( ! aioseo()->actionScheduler->isScheduled( $this->getAccessTokenAction ) ) { aioseo()->actionScheduler->scheduleSingle( $this->getAccessTokenAction, 0, [], true ); } } /** * Gets an access token from the server. * This is the one-time access token that includes 50 free credits. * * @since 4.8.4 * * @param bool $refresh Whether to refresh the access token. * @return void */ public function getAccessToken( $refresh = false ) { if ( ( aioseo()->sensitiveOptions->hasValue( 'aiAccessToken' ) || aioseo()->core->cache->get( 'ai_access_token_idle' ) ) && ! $refresh ) { return; } // Don't overwrite users who manually connected to their account (as opposed to via their license key). // Credits can still be refreshed via updateCredits() independently. if ( aioseo()->internalOptions->internal->ai->isManuallyConnected ) { return; } $response = aioseo()->helpers->wpRemotePost( $this->getApiUrl() . 'ai/auth/', [ 'body' => wp_json_encode( [ 'domain' => aioseo()->helpers->getSiteDomain() ] ) ] ); if ( is_wp_error( $response ) ) { aioseo()->core->cache->update( 'ai_access_token_idle', true, HOUR_IN_SECONDS ); return; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body ); if ( empty( $data->accessToken ) ) { aioseo()->core->cache->update( 'ai_access_token_idle', true, HOUR_IN_SECONDS ); return; } aioseo()->sensitiveOptions->set( 'aiAccessToken', $data->accessToken ); aioseo()->internalOptions->internal->ai->isTrialAccessToken = $data->isFree ?? false; aioseo()->internalOptions->internal->ai->isManuallyConnected = false; aioseo()->core->cache->update( 'ai_access_token_idle', true, 12 * HOUR_IN_SECONDS ); $this->updateCredits( true ); } /** * Gets the credit data from the server and updates our options. * * @since 4.8.4 * * @param bool $refresh Whether to refresh the credits forcefully. * @return void */ public function updateCredits( $refresh = false ) { if ( aioseo()->core->cache->get( 'ai_credits_idle' ) && ! $refresh ) { return; } if ( ! aioseo()->sensitiveOptions->hasValue( 'aiAccessToken' ) ) { return; } $response = aioseo()->helpers->wpRemoteGet( $this->getApiUrl() . 'ai/credits/', [ 'headers' => $this->getRequestHeaders() ] ); if ( is_wp_error( $response ) ) { aioseo()->core->cache->update( 'ai_credits_idle', true, HOUR_IN_SECONDS ); return; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body ); if ( empty( $data->success ) ) { if ( ! empty( $data->code ) && 'invalid-token' === $data->code ) { // Drop the access token in case it could not be found. aioseo()->sensitiveOptions->set( 'aiAccessToken', '' ); } aioseo()->core->cache->update( 'ai_credits_idle', true, HOUR_IN_SECONDS ); return; } aioseo()->core->cache->update( 'ai_credits_idle', true, 12 * HOUR_IN_SECONDS ); $orders = []; if ( ! empty( $data->orders ) ) { foreach ( $data->orders as $order ) { if ( empty( $order->total ) || ! isset( $order->remaining ) || ! isset( $order->expires ) ) { continue; } $orders[] = [ 'total' => intval( $order->total ), 'remaining' => intval( $order->remaining ), 'expires' => intval( $order->expires ) ]; } } aioseo()->internalOptions->internal->ai->credits->orders = $orders; aioseo()->internalOptions->internal->ai->credits->total = isset( $data->total ) ? intval( $data->total ) : 0; aioseo()->internalOptions->internal->ai->credits->remaining = isset( $data->remaining ) ? intval( $data->remaining ) : 0; if ( ! empty( $data->license ) ) { aioseo()->internalOptions->internal->ai->credits->license->total = intval( $data->license->total ); aioseo()->internalOptions->internal->ai->credits->license->remaining = intval( $data->license->remaining ); aioseo()->internalOptions->internal->ai->credits->license->expires = intval( $data->license->expires ); } else { aioseo()->internalOptions->internal->ai->credits->license->reset(); } if ( ! empty( $data->costPerFeature ) ) { aioseo()->internalOptions->internal->ai->costPerFeature = json_decode( wp_json_encode( $data->costPerFeature ), true ); } } /** * Returns the default request headers. * * @since 4.8.4 * * @return array The default request headers. */ public function getRequestHeaders() { $headers = [ 'Content-Type' => 'application/json', 'X-AIOSEO-Ai-Token' => aioseo()->sensitiveOptions->get( 'aiAccessToken' ), 'X-AIOSEO-Ai-Domain' => aioseo()->helpers->getSiteDomain() ]; return $headers; } /** * Returns the API URL of the licensing server. * * @since 4.8.4 * * @return string The URL. */ protected function getApiUrl() { if ( defined( 'AIOSEO_LICENSING_URL' ) ) { return AIOSEO_LICENSING_URL; } return $this->licensingUrl; } /** * Returns the AI Generator API URL for the given version. * * @since 4.8.4 * @version 4.8.8 Moved from {@see \AIOSEO\Plugin\Common\Api\Ai}. * @version 4.9.7 Added $version parameter; base URL no longer includes the version segment. * * @param string $version The API version (e.g. 'v1', 'v2'). Defaults to 'v1'. * @return string The AI Generator API URL. */ public function getAiGeneratorApiUrl( $version = 'v1' ) { if ( defined( 'AIOSEO_AI_GENERATOR_URL' ) ) { return AIOSEO_AI_GENERATOR_URL; } return $this->aiGeneratorApiBaseUrl . $version . '/'; } /** * Generates title suggestions based on the provided content and options. * * @since 4.9.6 * * @param array $data The data array. * @return array The result array. */ public function generateTitles( $data ) { $postId = $data['postId'] ?? 0; $postContent = $data['postContent'] ?? ''; $focusKeyword = $data['focusKeyword'] ?? ''; $rephrase = $data['rephrase'] ?? false; $titles = $data['titles'] ?? []; $options = $data['options'] ?? []; if ( ! current_user_can( 'edit_post', $postId ) ) { return [ 'success' => false, 'message' => 'Unauthorized.' ]; } $result = $this->callAiService( 'meta/title/', [ 'postContent' => $postContent, 'focusKeyword' => $focusKeyword, 'tone' => $options['tone'], 'audience' => $options['audience'], 'rephrase' => $rephrase, 'titles' => $titles ], 'titles', [ 'feature' => $rephrase ? 'rephrase' : 'titles' ] ); if ( ! $result['success'] ) { return $result; } $result['titles'] = array_map( [ aioseo()->helpers, 'decodeHtmlEntities' ], $result['titles'] ); $aioseoPost = Models\Post::getPost( $postId ); $aioseoPost->ai = Models\Post::getDefaultAiOptions( $aioseoPost->ai ); $aioseoPost->ai->titles = $result['titles']; $aioseoPost->save(); return $result; } /** * Generates description suggestions based on the provided content and options. * * @since 4.9.6 * * @param array $data The data array. * @return array The result array. */ public function generateDescriptions( $data ) { $postId = $data['postId'] ?? 0; $postContent = $data['postContent'] ?? ''; $focusKeyword = $data['focusKeyword'] ?? ''; $rephrase = $data['rephrase'] ?? false; $descriptions = $data['descriptions'] ?? []; $options = $data['options'] ?? []; if ( ! current_user_can( 'edit_post', $postId ) ) { return [ 'success' => false, 'message' => 'Unauthorized.' ]; } $result = $this->callAiService( 'meta/description/', [ 'postContent' => $postContent, 'focusKeyword' => $focusKeyword, 'tone' => $options['tone'], 'audience' => $options['audience'], 'rephrase' => $rephrase, 'descriptions' => $descriptions ], 'descriptions', [ 'feature' => $rephrase ? 'rephrase' : 'descriptions' ] ); if ( ! $result['success'] ) { return $result; } $result['descriptions'] = array_map( [ aioseo()->helpers, 'decodeHtmlEntities' ], $result['descriptions'] ); $aioseoPost = Models\Post::getPost( $postId ); $aioseoPost->ai = Models\Post::getDefaultAiOptions( $aioseoPost->ai ); $aioseoPost->ai->descriptions = $result['descriptions']; $aioseoPost->save(); return $result; } /** * Generates ALT text for an image using the AI service. * * @since 4.9.6 * @version 5.0.3 Checks cached credits before encoding the image. * * @param array $data The data array containing attachmentId. * @return array The result array. */ public function generateImageAlt( $data ) { $attachmentId = (int) ( $data['attachmentId'] ?? 0 ); $feature = 'imageAltText'; if ( ! aioseo()->helpers->attachmentIs( 'image', $attachmentId ) ) { return [ 'success' => false, 'code' => 'not_an_image', 'message' => "The attachment is not an image. (Attachment #$attachmentId)" ]; } // Bail before the costly base64 encoding when the cached balance already rules the call out. if ( ! $this->hasCreditsForFeature( $feature ) ) { return $this->getInsufficientCreditsError(); } $image = aioseo()->helpers->getBase64FromAttachment( $attachmentId ); if ( empty( $image ) ) { return [ 'success' => false, 'message' => "Could not encode image (attachment #$attachmentId)." ]; } $result = $this->callAiService( 'image/alt-text/', [ 'image' => $image ], 'altTexts', [ 'feature' => $feature ] ); if ( ! $result['success'] ) { return $result; } $result['altTexts'] = array_map( [ aioseo()->helpers, 'decodeHtmlEntities' ], $result['altTexts'] ); return $result; } /** * Generates schema markup using the AI service. * * @since 4.9.6 * * @param array $body The request body to forward to the AI service. * @return array The result array. */ public function generateSchemas( $body ) { $postId = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0; $result = $this->callAiService( 'schema/', $body, 'schemas', [ 'timeout' => 90, 'sanitize' => false, 'feature' => 'schemas' ] ); if ( ! $result['success'] ) { return $result; } $rawSchemas = json_decode( wp_json_encode( $result['schemas'] ), true ); $schemas = []; foreach ( $rawSchemas as $schemaEntry ) { $properties = $schemaEntry['properties'] ?? null; if ( empty( $properties ) ) { continue; } array_walk_recursive( $properties, function ( &$value ) { if ( is_string( $value ) ) { $value = wp_strip_all_tags( wp_check_invalid_utf8( trim( $value ) ) ); } } ); $confidence = isset( $schemaEntry['confidence'] ) ? (int) $schemaEntry['confidence'] : null; $reasoning = ! empty( $schemaEntry['reasoning'] ) ? wp_strip_all_tags( wp_check_invalid_utf8( trim( $schemaEntry['reasoning'] ) ) ) : ''; $schemas[] = [ 'schemaType' => $properties['@type'] ?? '', // Fallback to empty string, but '@type' is always present in the service response. 'schemaData' => $properties, 'confidence' => $confidence, 'reasoning' => $reasoning ]; } $aioseoPost = Models\Post::getPost( $postId ); $aioseoPost->ai = Models\Post::getDefaultAiOptions( $aioseoPost->ai ); $aioseoPost->ai->schemas = $schemas; $aioseoPost->save(); return [ 'success' => true, 'schemas' => $schemas ]; } /** * Truncates post content to the maximum length we send to the AI Generator service. * * @since 5.0.4 * * @param string $postContent The post content. * @return string The post content, at most self::MAX_POST_CONTENT_LENGTH characters. */ public function truncatePostContent( $postContent ) { if ( ! is_string( $postContent ) ) { return ''; } // Fast path: a UTF-8 string never has fewer bytes than characters. if ( self::MAX_POST_CONTENT_LENGTH >= strlen( $postContent ) ) { return $postContent; } // Over the cap, markup would spend characters the text needs, so shed it before cutting: // block delimiters and other comments first, then the tags themselves. if ( self::MAX_POST_CONTENT_LENGTH < aioseo()->helpers->stringLength( $postContent ) ) { $postContent = preg_replace( '//s', '', $postContent ) ?? $postContent; } if ( self::MAX_POST_CONTENT_LENGTH < aioseo()->helpers->stringLength( $postContent ) ) { $stripped = $this->stripMarkup( $postContent ); // Content that is all markup (or broken markup) is still better sent cut than empty. if ( '' !== trim( $stripped ) ) { $postContent = $stripped; } } $truncated = aioseo()->helpers->substring( $postContent, 0, self::MAX_POST_CONTENT_LENGTH ); // Over the cap in bytes but not in characters (multibyte scripts): nothing was cut, so // don't rewind. This also keeps the function idempotent. if ( $truncated === $postContent ) { return $postContent; } // Rewind past the half word the cap cut, unless that would drop most of the payload (one // unbroken token such as a data URI). A literal class, not `\s`, on a byte string. if ( preg_match( '/^(.*)[ \t\r\n][^ \t\r\n]*$/s', $truncated, $matches ) && strlen( $matches[1] ) > ( strlen( $truncated ) / 2 ) ) { $truncated = $matches[1]; } // Without mbstring, `substring()` cuts bytes and can split a character, and an invalid // tail makes `wp_json_encode()` fail for the whole body. Drop at most three bytes (a UTF-8 // character is at most four); content still invalid after that arrived invalid. for ( $i = 0; 3 > $i && '' !== $truncated && 1 !== preg_match( '//u', $truncated ); $i++ ) { $truncated = substr( $truncated, 0, -1 ); } return $truncated; } /** * Strips HTML from post content while keeping block-level elements on separate lines. * * @since 5.0.4 * * @param string $postContent The post content. * @return string The plain-text post content. */ private function stripMarkup( $postContent ) { // `strip_tags()` discards everything after an unclosed comment opener. $postContent = str_replace( '