Source: Providers/AWS/AmazonPolly.php

<?php
/**
 * Provides Text to Speech synthesis feature using Amazon Polly.
 *
 * @package Classifai\Providers\AWS
 * @since 3.1.0
 */

namespace Classifai\Providers\AWS;

use Classifai\Providers\Provider;
use Classifai\Features\TextToSpeech;
use WP_Error;
use Aws\Sdk;

class AmazonPolly extends Provider {

	const ID = 'aws_polly';

	/**
	 * AmazonPolly Text to Speech constructor.
	 *
	 * @param \Classifai\Features\Feature $feature_instance The feature instance.
	 */
	public function __construct( $feature_instance = null ) {
		$this->feature_instance = $feature_instance;

		do_action( 'classifai_' . static::ID . '_init', $this );
		add_action( 'wp_ajax_classifai_get_voice_dropdown', [ $this, 'get_voice_dropdown' ] );
	}

	/**
	 * Render the provider fields.
	 */
	public function render_provider_fields() {
		$settings = $this->feature_instance->get_settings( static::ID );

		add_settings_field(
			'access_key_id',
			esc_html__( 'Access key', 'classifai' ),
			[ $this->feature_instance, 'render_input' ],
			$this->feature_instance->get_option_name(),
			$this->feature_instance->get_option_name() . '_section',
			[
				'option_index'  => static::ID,
				'label_for'     => 'access_key_id',
				'input_type'    => 'text',
				'default_value' => $settings['access_key_id'],
				'class'         => 'large-text classifai-provider-field hidden provider-scope-' . static::ID,
				'description'   => $this->feature_instance->is_configured_with_provider( static::ID ) ?
					'' :
					sprintf(
						wp_kses(
							/* translators: %1$s is replaced with the OpenAI sign up URL */
							__( 'Enter the AWS access key. Please follow the steps given <a title="AWS documentation" href="%1$s">here</a> to generate AWS credentials.', 'classifai' ),
							[
								'a' => [
									'href'  => [],
									'title' => [],
								],
							]
						),
						esc_url( 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey' )
					),
			]
		);

		add_settings_field(
			'secret_access_key',
			esc_html__( 'Secret access key', 'classifai' ),
			[ $this->feature_instance, 'render_input' ],
			$this->feature_instance->get_option_name(),
			$this->feature_instance->get_option_name() . '_section',
			[
				'option_index'  => static::ID,
				'label_for'     => 'secret_access_key',
				'input_type'    => 'password',
				'default_value' => $settings['secret_access_key'],
				'class'         => 'classifai-provider-field hidden provider-scope-' . static::ID,
				'description'   => $this->feature_instance->is_configured_with_provider( static::ID ) ?
					'' :
					esc_html__( 'Enter the AWS secret access key.', 'classifai' ),
			]
		);

		add_settings_field(
			'aws_region',
			esc_html__( 'Region', 'classifai' ),
			[ $this->feature_instance, 'render_input' ],
			$this->feature_instance->get_option_name(),
			$this->feature_instance->get_option_name() . '_section',
			[
				'option_index'  => static::ID,
				'label_for'     => 'aws_region',
				'input_type'    => 'text',
				'default_value' => $settings['aws_region'],
				'class'         => 'large-text classifai-provider-field hidden provider-scope-' . static::ID,
				'description'   => $this->feature_instance->is_configured_with_provider( static::ID ) ?
					'' :
					wp_kses(
						__( 'Enter the AWS Region. eg: <code>us-east-1</code>', 'classifai' ),
						[
							'code' => [],
						]
					),
			]
		);

		add_settings_field(
			'voice_engine',
			esc_html__( 'Engine', 'classifai' ),
			[ $this->feature_instance, 'render_select' ],
			$this->feature_instance->get_option_name(),
			$this->feature_instance->get_option_name() . '_section',
			[
				'option_index'  => static::ID,
				'label_for'     => 'voice_engine',
				'options'       => array(
					'standard'  => esc_html__( 'Standard', 'classifai' ),
					'neural'    => esc_html__( 'Neural', 'classifai' ),
					'long-form' => esc_html__( 'Long Form', 'classifai' ),
				),
				'default_value' => $settings['voice_engine'],
				'class'         => 'classifai-provider-field hidden provider-scope-' . static::ID,
				'description'   => $this->feature_instance->is_configured_with_provider( static::ID ) ?
					'' :
					sprintf(
						wp_kses(
							/* translators: %1$s is replaced with the OpenAI sign up URL */
							__( 'Amazon Polly offers <a href="%1$s">Long-Form</a>, <a href="%2$s">Neural</a> and Standard text-to-speech voices. Please check the <a title="Pricing" href="%3$s">documentation</a> to review pricing for Long-Form, Neural and Standard usage.', 'classifai' ),
							[
								'a' => [
									'href'  => [],
									'title' => [],
								],
							]
						),
						esc_url( 'https://docs.aws.amazon.com/polly/latest/dg/long-form-voice-overview.html' ),
						esc_url( 'https://docs.aws.amazon.com/polly/latest/dg/NTTS-main.html' ),
						esc_url( 'https://aws.amazon.com/polly/pricing/' )
					),
			]
		);

		$voices_options = $this->get_voices_select_options( $settings['voice_engine'] ?? '' );
		if ( ! empty( $voices_options ) ) {
			add_settings_field(
				'voice',
				esc_html__( 'Voice', 'classifai' ),
				[ $this->feature_instance, 'render_select' ],
				$this->feature_instance->get_option_name(),
				$this->feature_instance->get_option_name() . '_section',
				[
					'option_index'  => static::ID,
					'label_for'     => 'voice',
					'options'       => $voices_options,
					'default_value' => $settings['voice'],
					'class'         => 'classifai-aws-polly-voices classifai-provider-field hidden provider-scope-' . static::ID,
				]
			);
		}

		do_action( 'classifai_' . static::ID . '_render_provider_fields', $this );
	}

	/**
	 * Returns the default settings for this provider.
	 *
	 * @return array
	 */
	public function get_default_provider_settings(): array {
		$common_settings = [
			'access_key_id'     => '',
			'secret_access_key' => '',
			'aws_region'        => '',
			'authenticated'     => false,
			'voice_engine'      => 'standard',
			'voices'            => [],
			'voice'             => '',
		];

		switch ( $this->feature_instance::ID ) {
			case TextToSpeech::ID:
				return $common_settings;
		}

		return [];
	}

	/**
	 * Sanitization callback for settings.
	 *
	 * @param array $new_settings The settings being saved.
	 * @return array
	 */
	public function sanitize_settings( array $new_settings ): array {
		$settings               = $this->feature_instance->get_settings();
		$is_credentials_changed = false;

		$new_settings[ static::ID ]['authenticated'] = $settings[ static::ID ]['authenticated'];
		$new_settings[ static::ID ]['voices']        = $settings[ static::ID ]['voices'];

		if (
			! empty( $new_settings[ static::ID ]['access_key_id'] ) &&
			! empty( $new_settings[ static::ID ]['secret_access_key'] ) &&
			! empty( $new_settings[ static::ID ]['aws_region'] )
		) {
			$new_access_key_id     = sanitize_text_field( $new_settings[ static::ID ]['access_key_id'] );
			$new_secret_access_key = sanitize_text_field( $new_settings[ static::ID ]['secret_access_key'] );
			$new_aws_region        = sanitize_text_field( $new_settings[ static::ID ]['aws_region'] );

			if (
				$new_access_key_id !== $settings[ static::ID ]['access_key_id'] ||
				$new_secret_access_key !== $settings[ static::ID ]['secret_access_key'] ||
				$new_aws_region !== $settings[ static::ID ]['aws_region']
			) {
				$is_credentials_changed = true;
			}

			if ( $is_credentials_changed ) {
				$new_settings[ static::ID ]['access_key_id']     = $new_access_key_id;
				$new_settings[ static::ID ]['secret_access_key'] = $new_secret_access_key;
				$new_settings[ static::ID ]['aws_region']        = $new_aws_region;
				$new_settings[ static::ID ]['voices']            = $this->connect_to_service(
					array(
						'access_key_id'     => $new_access_key_id,
						'secret_access_key' => $new_secret_access_key,
						'aws_region'        => $new_aws_region,
					)
				);

				if ( ! empty( $new_settings[ static::ID ]['voices'] ) ) {
					$new_settings[ static::ID ]['authenticated'] = true;
				} else {
					$new_settings[ static::ID ]['voices']        = [];
					$new_settings[ static::ID ]['authenticated'] = false;
				}
			}
		} else {
			$new_settings[ static::ID ]['access_key_id']     = $settings[ static::ID ]['access_key_id'];
			$new_settings[ static::ID ]['secret_access_key'] = $settings[ static::ID ]['secret_access_key'];
			$new_settings[ static::ID ]['aws_region']        = $settings[ static::ID ]['aws_region'];

			add_settings_error(
				$this->feature_instance->get_option_name(),
				'classifai-ams-polly-auth-empty',
				esc_html__( 'One or more credentials required to connect to the Amazon Polly service is empty.', 'classifai' ),
				'error'
			);
		}

		$new_settings[ static::ID ]['voice'] = sanitize_text_field( $new_settings[ static::ID ]['voice'] ?? $settings[ static::ID ]['voice'] );

		return $new_settings;
	}

	/**
	 * Connects to the Amazon Polly service.
	 *
	 * @param array $args Overridable args.
	 * @return array
	 */
	public function connect_to_service( array $args = array() ): array {
		$settings = $this->feature_instance->get_settings( static::ID );

		$default = array(
			'access_key_id'     => $settings[ static::ID ]['access_key_id'] ?? '',
			'secret_access_key' => $settings[ static::ID ]['secret_access_key'] ?? '',
			'aws_region'        => $settings[ static::ID ]['aws_region'] ?? 'us-east-1',
		);

		$default = wp_parse_args( $args, $default );

		// Return if credentials don't exist.
		if ( empty( $default['access_key_id'] ) || empty( $default['secret_access_key'] ) ) {
			return array();
		}

		try {
			/**
			 * Filters the return value of the connect to services function.
			 *
			 * Returning a non-false value from the filter will short-circuit the describe voices request and return early with that value.
			 * This filter is useful for E2E tests.
			 *
			 * @since 3.1.0
			 * @hook classifai_aws_polly_pre_connect_to_service
			 *
			 * @param {bool} $pre The value of pre connect to service. Default false. non-false value will short-circuit the describe voices request.
			 *
			 * @return {bool|mixed} The filtered value of connect to service.
			 */
			$pre = apply_filters( 'classifai_' . self::ID . '_pre_connect_to_service', false );

			if ( false !== $pre ) {
				return $pre;
			}

			$polly_client = $this->get_polly_client( $args );
			$polly_voices = $polly_client->describeVoices();
			return $polly_voices->get( 'Voices' );
		} catch ( \Exception $e ) {
			add_settings_error(
				$this->feature_instance->get_option_name(),
				'aws-polly-auth-failed',
				esc_html__( 'Connection to Amazon Polly failed.', 'classifai' ),
				'error'
			);
			return array();
		}
	}

	/**
	 * Returns HTML select dropdown options for voices.
	 *
	 * @param string $engine Engine type.
	 * @return array
	 */
	public function get_voices_select_options( string $engine = '' ): array {
		$settings = $this->feature_instance->get_settings( static::ID );
		$voices   = $settings['voices'];
		$options  = array();

		if ( false === $voices ) {
			return $options;
		}

		foreach ( $voices as $voice ) {
			if (
				! is_array( $voice ) ||
				empty( $voice ) ||
				(
					! empty( $engine ) &&
					! in_array( $engine, $voice['SupportedEngines'], true )
				)
			) {
				continue;
			}

			$options[ $voice['Id'] ] = sprintf(
				'%1$s - %2$s (%3$s)',
				esc_html( $voice['LanguageName'] ),
				esc_html( $voice['Name'] ),
				esc_html( $voice['Gender'] )
			);
		}

		// Sort the options.
		asort( $options );

		return $options;
	}

	/**
	 * Synthesizes speech from a post item.
	 *
	 * @param int $post_id Post ID.
	 * @return string|WP_Error
	 */
	public function synthesize_speech( int $post_id ) {
		if ( empty( $post_id ) ) {
			return new WP_Error(
				'aws_polly_post_id_missing',
				esc_html__( 'Post ID missing.', 'classifai' )
			);
		}

		// We skip the user cap check if running under WP-CLI.
		if ( ! current_user_can( 'edit_post', $post_id ) && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) {
			return new WP_Error(
				'aws_polly_user_not_authorized',
				esc_html__( 'Unauthorized user.', 'classifai' )
			);
		}

		$feature             = new TextToSpeech();
		$settings            = $feature->get_settings();
		$post_content        = $feature->normalize_post_content( $post_id );
		$content_hash        = get_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, true );
		$saved_attachment_id = (int) get_post_meta( $post_id, $feature::AUDIO_ID_KEY, true );

		// Don't regenerate the audio file it it already exists and the content hasn't changed.
		if ( $saved_attachment_id ) {

			// Check if the audio file exists.
			$audio_attachment_url = wp_get_attachment_url( $saved_attachment_id );

			if ( $audio_attachment_url && ! empty( $content_hash ) && ( md5( $post_content ) === $content_hash ) ) {
				return $saved_attachment_id;
			}
		}

		if ( mb_strlen( $post_content ) > 3000 ) {
			return new WP_Error(
				'aws_polly_length_error',
				esc_html__( 'Maximum text length has been exceeded.', 'classifai' )
			);
		}

		$voice = $settings[ static::ID ]['voice'] ?? '';

		try {
			/**
			 * Filter Synthesize speech args.
			 *
			 * @since 3.1.0
			 * @hook classifai_aws_polly_synthesize_speech_args
			 *
			 * @param {array} Associative array of synthesize speech args.
			 * @param {int}   $post_id Post ID.
			 * @param {object} $provider_instance Provider instance.
			 * @param {object} $feature_instance Feature instance.
			 *
			 * @return {array} The filtered array of synthesize speech args.
			 */
			$synthesize_data = apply_filters(
				'classifai_' . self::ID . '_synthesize_speech_args',
				array(
					'OutputFormat' => 'mp3',
					'Text'         => $post_content,
					'TextType'     => 'text',
					'Engine'       => $settings[ static::ID ]['voice_engine'] ?? 'standard',
					'VoiceId'      => $voice,
				),
				$post_id,
				$this,
				$this->feature_instance
			);

			/**
			 * Filters the return value of the synthesize speech function.
			 *
			 * Returning a non-false value from the filter will short-circuit the synthesize speech request and return early with that value.
			 * This filter is useful for E2E tests.
			 *
			 * @since 3.1.0
			 * @hook classifai_aws_polly_pre_synthesize_speech
			 *
			 * @param {bool}   $pre A value of pre synthesize speech. Default false.
			 * @param {array}  $synthesize_data HTTP request arguments.
			 *
			 * @return {bool|mixed} The filtered value of pre synthesize speech.
			 */
			$pre = apply_filters( 'classifai_' . self::ID . '_pre_synthesize_speech', false, $synthesize_data );

			if ( false !== $pre ) {
				return $pre;
			}

			$polly_client = $this->get_polly_client();
			$result       = $polly_client->synthesizeSpeech( $synthesize_data );

			update_post_meta( $post_id, TextToSpeech::AUDIO_HASH_KEY, md5( $post_content ) );
			$contents = $result['AudioStream']->getContents();
			return $contents;
		} catch ( \Exception $e ) {
			return new WP_Error(
				'aws_polly_http_error',
				esc_html( $e->getMessage() )
			);
		}
	}

	/**
	 * Common entry point for all REST endpoints for this provider.
	 *
	 * @param int    $post_id       The post ID we're processing.
	 * @param string $route_to_call The name of the route we're going to be processing.
	 * @param array  $args          Optional arguments to pass to the route.
	 * @return array|string|WP_Error
	 */
	public function rest_endpoint_callback( $post_id, string $route_to_call = '', array $args = [] ) {
		if ( ! $post_id || ! get_post( $post_id ) ) {
			return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required.', 'classifai' ) );
		}

		$route_to_call = strtolower( $route_to_call );
		$return        = '';

		// Handle all of our routes.
		switch ( $route_to_call ) {
			case 'synthesize':
				$return = $this->synthesize_speech( $post_id, $args );
				break;
		}

		return $return;
	}

	/**
	 * Returns the debug information for the provider settings.
	 *
	 * @return array
	 */
	public function get_debug_information(): array {
		$settings          = $this->feature_instance->get_settings();
		$provider_settings = $settings[ static::ID ];
		$debug_info        = [];

		if ( $this->feature_instance instanceof TextToSpeech ) {
			$post_types = array_filter(
				$settings['post_types'],
				function ( $value ) {
					return '0' !== $value;
				}
			);

			$debug_info[ __( 'Allowed post types', 'classifai' ) ]       = implode( ', ', $post_types );
			$debug_info[ __( 'Voice', 'classifai' ) ]                    = $provider_settings['voice'];
			$debug_info[ __( 'Latest response - Voices', 'classifai' ) ] = $this->get_formatted_latest_response( $provider_settings['voices'] );
		}

		return apply_filters(
			'classifai_' . self::ID . '_debug_information',
			$debug_info,
			$settings,
			$this->feature_instance
		);
	}

	/**
	 * Returns aws polly client.
	 *
	 * @param array $aws_config AWS configuration array.
	 * @return \Aws\Polly\PollyClient|null
	 */
	public function get_polly_client( array $aws_config = array() ) {
		$settings = $this->feature_instance->get_settings( static::ID );

		$default = array(
			'access_key_id'     => $settings['access_key_id'] ?? '',
			'secret_access_key' => $settings['secret_access_key'] ?? '',
			'aws_region'        => $settings['aws_region'] ?? 'us-east-1',
		);

		$default = wp_parse_args( $aws_config, $default );

		// Return if credentials don't exist.
		if ( empty( $default['access_key_id'] ) || empty( $default['secret_access_key'] ) ) {
			return null;
		}

		// Set the AWS SDK configuration.
		$aws_sdk_config = [
			'region'      => $default['aws_region'] ?? 'us-east-1',
			'version'     => 'latest',
			'ua_append'   => [ 'request-source/classifai' ],
			'credentials' => [
				'key'    => $default['access_key_id'],
				'secret' => $default['secret_access_key'],
			],
		];

		$sdk = new Sdk( $aws_sdk_config );
		return $sdk->createPolly();
	}

	/**
	 * Returns the voice dropdown for the selected engine.
	 */
	public function get_voice_dropdown() {
		if ( ! wp_doing_ajax() ) {
			return;
		}

		// Nonce check.
		if ( ! check_ajax_referer( 'classifai', 'nonce', false ) ) {
			$error = new WP_Error( 'classifai_nonce_error', __( 'Nonce could not be verified.', 'classifai' ) );
			wp_send_json_error( $error );
			exit();
		}

		// Set the feature instance if it's not already set.
		if ( ! $this->feature_instance instanceof TextToSpeech ) {
			$this->feature_instance = new TextToSpeech();
		}

		// Attachment ID check.
		$engine         = isset( $_POST['engine'] ) ? sanitize_text_field( wp_unslash( $_POST['engine'] ) ) : 'standard';
		$settings       = $this->feature_instance->get_settings( static::ID );
		$voices_options = $this->get_voices_select_options( $engine );

		ob_start();
		$this->feature_instance->render_select(
			[
				'option_index'  => static::ID,
				'label_for'     => 'voice',
				'options'       => $voices_options,
				'default_value' => $settings['voice'],
				'class'         => 'classifai-provider-field hidden provider-scope-' . static::ID,
			]
		);
		$voice_dropdown = ob_get_clean();

		wp_send_json_success( $voice_dropdown );
	}
}