get_error_message(); } return $made; } private static function create_budget( $creds, $token, array $plan ) { $cid = self::customer_id( $creds ); $micros = (int) round( (float) $plan['daily_budget'] * self::MICROS ); $res = self::mutate( $creds, $token, "customers/{$cid}/campaignBudgets:mutate", array( 'operations' => array( array( 'create' => array( 'name' => self::unique_name( (string) $plan['name'] . ' budget' ), 'amountMicros' => (string) $micros, 'deliveryMethod' => 'STANDARD', 'explicitlyShared' => false, ), ), ), ) ); if ( is_wp_error( $res ) ) { return $res; } return self::first_resource( $res ); } private static function create_campaign( $creds, $token, array $plan, $budget_resource ) { $cid = self::customer_id( $creds ); $res = self::mutate( $creds, $token, "customers/{$cid}/campaigns:mutate", array( 'operations' => array( array( 'create' => array( 'name' => self::unique_name( (string) $plan['name'] ), 'status' => 'PAUSED', 'advertisingChannelType' => 'SEARCH', 'campaignBudget' => $budget_resource, 'manualCpc' => array( 'enhancedCpcEnabled' => false ), 'networkSettings' => array( 'targetGoogleSearch' => true, 'targetSearchNetwork' => true, 'targetContentNetwork' => false, 'targetPartnerSearchNetwork' => false, ), ), ), ), ) ); if ( is_wp_error( $res ) ) { return $res; } return self::first_resource( $res ); } private static function create_ad_group( $creds, $token, $campaign_resource, array $group ) { $cid = self::customer_id( $creds ); $res = self::mutate( $creds, $token, "customers/{$cid}/adGroups:mutate", array( 'operations' => array( array( 'create' => array( 'name' => self::unique_name( (string) $group['name'] ), 'campaign' => $campaign_resource, 'status' => 'ENABLED', 'type' => 'SEARCH_STANDARD', 'cpcBidMicros' => (string) (int) round( 0.5 * self::MICROS ), ), ), ), ) ); if ( is_wp_error( $res ) ) { return $res; } return self::first_resource( $res ); } private static function create_ad( $creds, $token, $ad_group_resource, array $group, array $plan ) { $headlines = self::assets( isset( $group['headlines'] ) ? (array) $group['headlines'] : array(), AIEO_Ads_Composer::HEADLINE_MAX, AIEO_Ads_Composer::HEADLINES_MAX ); $descs = self::assets( isset( $group['descriptions'] ) ? (array) $group['descriptions'] : array(), AIEO_Ads_Composer::DESCRIPTION_MAX, AIEO_Ads_Composer::DESCRIPTIONS_MAX ); if ( count( $headlines ) < AIEO_Ads_Composer::HEADLINES_MIN || count( $descs ) < AIEO_Ads_Composer::DESCRIPTIONS_MIN ) { return new WP_Error( 'aieo_ads_assets', sprintf( __( 'Google needs at least 3 headlines and 2 descriptions within its character limits; this plan has %1$d and %2$d.', 'ai-eshop-optimizer' ), count( $headlines ), count( $descs ) ) ); } $final_url = isset( $group['final_url'] ) && '' !== $group['final_url'] ? (string) $group['final_url'] : (string) ( isset( $plan['final_url'] ) ? $plan['final_url'] : '' ); if ( ! filter_var( $final_url, FILTER_VALIDATE_URL ) ) { return new WP_Error( 'aieo_ads_url', __( 'The landing page URL is not valid.', 'ai-eshop-optimizer' ) ); } $rsa = array( 'headlines' => array_map( static function ( $t ) { return array( 'text' => $t ); }, $headlines ), 'descriptions' => array_map( static function ( $t ) { return array( 'text' => $t ); }, $descs ), ); $paths = isset( $group['paths'] ) ? array_values( (array) $group['paths'] ) : array(); if ( isset( $paths[0] ) && '' !== $paths[0] ) { $rsa['path1'] = (string) $paths[0]; } if ( isset( $paths[1] ) && '' !== $paths[1] ) { $rsa['path2'] = (string) $paths[1]; } $cid = self::customer_id( $creds ); $res = self::mutate( $creds, $token, "customers/{$cid}/adGroupAds:mutate", array( 'operations' => array( array( 'create' => array( 'adGroup' => $ad_group_resource, 'status' => 'ENABLED', 'ad' => array( 'finalUrls' => array( $final_url ), 'responsiveSearchAd' => $rsa, ), ), ), ), ) ); if ( is_wp_error( $res ) ) { return $res; } return self::first_resource( $res ); } private static function create_keywords( $creds, $token, $ad_group_resource, array $keywords ) { $keywords = array_slice( array_values( array_filter( array_map( 'strval', $keywords ) ) ), 0, AIEO_Ads_Composer::KEYWORDS_MAX ); if ( ! $keywords ) { return 0; } $ops = array(); foreach ( $keywords as $kw ) { $ops[] = array( 'create' => array( 'adGroup' => $ad_group_resource, 'status' => 'ENABLED', 'keyword' => array( 'text' => $kw, 'matchType' => 'PHRASE' ), ), ); } $cid = self::customer_id( $creds ); $res = self::mutate( $creds, $token, "customers/{$cid}/adGroupCriteria:mutate", array( 'operations' => $ops ) ); if ( is_wp_error( $res ) ) { return $res; } return isset( $res['results'] ) ? count( (array) $res['results'] ) : count( $ops ); } private static function rollback( $creds, $token, array $made ) { $cid = self::customer_id( $creds ); $order = array( 'ad' => 'adGroupAds', 'ad_group' => 'adGroups', 'campaign' => 'campaigns', 'budget' => 'campaignBudgets', ); foreach ( $order as $kind => $service ) { if ( empty( $made[ $kind ] ) || ! is_string( $made[ $kind ] ) ) { continue; } self::mutate( $creds, $token, "customers/{$cid}/{$service}:mutate", array( 'operations' => array( array( 'remove' => $made[ $kind ] ) ) ) ); } } private static function mutate( $creds, $token, $path, array $body ) { $url = 'https://googleads.googleapis.com/' . self::API_VERSION . '/' . ltrim( $path, '/' ); $headers = array( 'Authorization' => 'Bearer ' . $token, 'developer-token' => (string) ( isset( $creds['developer_token'] ) ? $creds['developer_token'] : '' ), 'Content-Type' => 'application/json', ); if ( ! empty( $creds['login_customer_id'] ) ) { $headers['login-customer-id'] = preg_replace( '/\D/', '', (string) $creds['login_customer_id'] ); } $r = wp_remote_post( $url, array( 'timeout' => self::TIMEOUT_MUTATE, 'headers' => $headers, 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $r ) ) { return $r; } $code = (int) wp_remote_retrieve_response_code( $r ); $json = json_decode( (string) wp_remote_retrieve_body( $r ), true ); if ( $code < 200 || $code >= 300 ) { $msg = isset( $json['error']['message'] ) ? (string) $json['error']['message'] : ( 'HTTP ' . $code ); return new WP_Error( 'aieo_ads_http', sprintf( 'Google Ads HTTP %1$d: %2$s', $code, $msg ) ); } if ( ! empty( $json['partialFailureError']['message'] ) ) { return new WP_Error( 'aieo_ads_partial', 'Google Ads: ' . (string) $json['partialFailureError']['message'] ); } return is_array( $json ) ? $json : array(); } private static function access_token( $creds ) { $r = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'timeout' => self::TIMEOUT_TOKEN, 'body' => array( 'client_id' => (string) ( isset( $creds['client_id'] ) ? $creds['client_id'] : '' ), 'client_secret' => (string) ( isset( $creds['client_secret'] ) ? $creds['client_secret'] : '' ), 'refresh_token' => (string) ( isset( $creds['refresh_token'] ) ? $creds['refresh_token'] : '' ), 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $r ) ) { return $r; } $body = json_decode( (string) wp_remote_retrieve_body( $r ), true ); if ( empty( $body['access_token'] ) ) { $detail = isset( $body['error_description'] ) ? (string) $body['error_description'] : __( 'no access token returned', 'ai-eshop-optimizer' ); return new WP_Error( 'aieo_ads_token', sprintf( __( 'Could not obtain a Google access token (%s). Re-connect Google Ads under Ad-spend connectors.', 'ai-eshop-optimizer' ), $detail ) ); } return (string) $body['access_token']; } private static function credentials() { if ( ! class_exists( 'AIEO_DMM_Ad_Connectors' ) ) { return new WP_Error( 'aieo_ads_no_conn', __( 'Ad-spend connectors are not available.', 'ai-eshop-optimizer' ) ); } $c = AIEO_DMM_Ad_Connectors::credentials( 'google' ); foreach ( array( 'customer_id', 'developer_token', 'client_id', 'client_secret', 'refresh_token' ) as $k ) { if ( empty( $c[ $k ] ) ) { return new WP_Error( 'aieo_ads_creds', __( 'Google Ads is not fully connected — add the customer id, developer token and OAuth details under Ad-spend connectors.', 'ai-eshop-optimizer' ) ); } } return $c; } private static function customer_id( $creds ) { return preg_replace( '/\D/', '', (string) ( isset( $creds['customer_id'] ) ? $creds['customer_id'] : '' ) ); } private static function first_resource( array $res ) { if ( ! empty( $res['results'][0]['resourceName'] ) ) { return (string) $res['results'][0]['resourceName']; } return new WP_Error( 'aieo_ads_no_resource', __( 'Google Ads accepted the request but returned no resource name.', 'ai-eshop-optimizer' ) ); } private static function id_from_resource( $resource ) { $parts = explode( '/', (string) $resource ); return (string) end( $parts ); } private static function unique_name( $base ) { $base = trim( preg_replace( '/\s+/u', ' ', (string) $base ) ); $base = mb_substr( $base, 0, 100 ); return $base . ' · ' . gmdate( 'ymd-His' ); } private static function assets( array $items, $max, $limit ) { $out = array(); $seen = array(); foreach ( $items as $t ) { if ( ! is_scalar( $t ) ) { continue; } $t = trim( preg_replace( '/\s+/u', ' ', (string) $t ) ); if ( '' === $t || mb_strlen( $t ) > $max ) { continue; } $k = mb_strtolower( $t ); if ( isset( $seen[ $k ] ) ) { continue; } $seen[ $k ] = true; $out[] = $t; if ( count( $out ) >= $limit ) { break; } } return $out; } private static function fail( $plan_id, $err ) { AIEO_Ads_Plan::mark_failed( $plan_id, $err->get_error_message() ); return $err; } public static function manage_url( array $external ) { $cid = isset( $external['customer_id'] ) ? preg_replace( '/\D/', '', (string) $external['customer_id'] ) : ''; $cmp = isset( $external['campaign_id'] ) ? preg_replace( '/\D/', '', (string) $external['campaign_id'] ) : ''; if ( '' === $cid ) { return ''; } $url = 'https://ads.google.com/aw/campaigns?ocid=&__c=' . rawurlencode( $cid ); if ( '' !== $cmp ) { $url .= '&campaignId=' . rawurlencode( $cmp ); } return $url; } }