wpdb = $wpdb; } /** * Generate the OAuth2 authorization URL. * * @return string Authorization URL to redirect the user to. */ public function get_auth_url(): string { $client_id = get_option( 'aicommerce_gmc_client_id', '' ); $redirect_uri = admin_url( 'admin.php?page=tyralix-aicommerce&action=gmc_oauth' ); $params = array( 'client_id' => $client_id, 'redirect_uri' => $redirect_uri, 'response_type' => 'code', 'scope' => self::SCOPE, 'access_type' => 'offline', 'prompt' => 'consent', ); return self::AUTH_ENDPOINT . '?' . http_build_query( $params, '', '&' ); } /** * Handle the OAuth2 callback — exchange code for tokens. * * @param string $code The authorization code from Google. * * @return bool True on success, false on failure. */ public function handle_oauth_callback( string $code ): bool { $client_id = get_option( 'aicommerce_gmc_client_id', '' ); $client_secret = get_option( 'aicommerce_gmc_client_secret', '' ); $redirect_uri = admin_url( 'admin.php?page=tyralix-aicommerce&action=gmc_oauth' ); $response = wp_remote_post( self::TOKEN_ENDPOINT, array( 'timeout' => 30, 'body' => array( 'code' => $code, 'client_id' => $client_id, 'client_secret' => $client_secret, 'redirect_uri' => $redirect_uri, 'grant_type' => 'authorization_code', ), ) ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['access_token'] ) ) { return false; } // Store encrypted tokens. $expiry = time() + ( (int) ( $body['expires_in'] ?? 3600 ) ); update_option( 'aicommerce_gmc_access_token', $this->encrypt( $body['access_token'] ), false ); update_option( 'aicommerce_gmc_token_expiry', $expiry, false ); if ( ! empty( $body['refresh_token'] ) ) { update_option( 'aicommerce_gmc_refresh_token', $this->encrypt( $body['refresh_token'] ), false ); } return true; } /** * Get a valid access token, refreshing if expired. * * @return string|null Access token or null if not connected. */ public function get_access_token(): ?string { $encrypted_token = get_option( 'aicommerce_gmc_access_token', '' ); if ( empty( $encrypted_token ) ) { return null; } $expiry = (int) get_option( 'aicommerce_gmc_token_expiry', 0 ); // If token is still valid (with 60s buffer), return it. if ( $expiry > time() + 60 ) { return $this->decrypt( $encrypted_token ); } // Try to refresh. if ( $this->refresh_token() ) { $encrypted_token = get_option( 'aicommerce_gmc_access_token', '' ); return $this->decrypt( $encrypted_token ); } return null; } /** * Submit feed URL to Google Merchant Center. * * @param string $feed_url The public URL of the feed file. * * @return bool True on success, false on failure. */ public function submit_feed( string $feed_url ): bool { $access_token = $this->get_access_token(); if ( null === $access_token ) { return false; } $merchant_id = get_option( 'aicommerce_gmc_merchant_id', '' ); if ( empty( $merchant_id ) ) { return false; } $api_url = self::API_BASE . '/' . $merchant_id . '/datafeeds'; $feed_data = array( 'name' => get_bloginfo( 'name' ) . ' — AICommerce Feed', 'contentType' => 'products', 'fetchSchedule' => array( 'fetchUrl' => $feed_url, ), ); $response = wp_remote_post( $api_url, array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $feed_data ), ) ); if ( is_wp_error( $response ) ) { $this->update_feed_status( 'error', $response->get_error_message() ); return false; } $code = wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { $this->update_feed_status( 'complete', '' ); return true; } $body = wp_remote_retrieve_body( $response ); $this->update_feed_status( 'error', 'GMC API error (' . $code . '): ' . $body ); return false; } /** * Check if connected to GMC (has merchant ID + valid tokens). * * @return bool True if connected. */ public function is_connected(): bool { $merchant_id = get_option( 'aicommerce_gmc_merchant_id', '' ); if ( empty( $merchant_id ) ) { return false; } $token = get_option( 'aicommerce_gmc_access_token', '' ); $refresh = get_option( 'aicommerce_gmc_refresh_token', '' ); return ! empty( $token ) && ! empty( $refresh ); } /** * Schedule daily feed submission to GMC. * * @return void */ public function schedule_daily_submission(): void { if ( ! $this->is_connected() ) { return; } if ( ! wp_next_scheduled( 'aicommerce_gmc_submit' ) ) { wp_schedule_event( time(), 'daily', 'aicommerce_gmc_submit' ); } } /** * Disconnect from GMC — clear all stored tokens. * * @return void */ public function disconnect(): void { delete_option( 'aicommerce_gmc_access_token' ); delete_option( 'aicommerce_gmc_refresh_token' ); delete_option( 'aicommerce_gmc_token_expiry' ); $timestamp = wp_next_scheduled( 'aicommerce_gmc_submit' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'aicommerce_gmc_submit' ); } } /** * Refresh the access token using the refresh token. * * @return bool True on success. */ private function refresh_token(): bool { $encrypted_refresh = get_option( 'aicommerce_gmc_refresh_token', '' ); if ( empty( $encrypted_refresh ) ) { return false; } $refresh_token = $this->decrypt( $encrypted_refresh ); $client_id = get_option( 'aicommerce_gmc_client_id', '' ); $client_secret = get_option( 'aicommerce_gmc_client_secret', '' ); $response = wp_remote_post( self::TOKEN_ENDPOINT, array( 'timeout' => 30, 'body' => array( 'refresh_token' => $refresh_token, 'client_id' => $client_id, 'client_secret' => $client_secret, 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $response ) ) { return false; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['access_token'] ) ) { return false; } $expiry = time() + ( (int) ( $body['expires_in'] ?? 3600 ) ); update_option( 'aicommerce_gmc_access_token', $this->encrypt( $body['access_token'] ), false ); update_option( 'aicommerce_gmc_token_expiry', $expiry, false ); return true; } /** * Encrypt a value using AES-256-CBC with WordPress auth keys. * * @param string $value The plaintext value. * * @return string Encrypted and base64-encoded value. */ private function encrypt( string $value ): string { if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) { return base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } $key = hash( 'sha256', AUTH_KEY, true ); $iv = substr( hash( 'sha256', AUTH_SALT ), 0, 16 ); $encrypted = openssl_encrypt( $value, 'AES-256-CBC', $key, 0, $iv ); return false !== $encrypted ? $encrypted : base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } /** * Decrypt a value encrypted with the encrypt method. * * @param string $value The encrypted value. * * @return string Decrypted plaintext value. */ private function decrypt( string $value ): string { if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) { return base64_decode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode } $key = hash( 'sha256', AUTH_KEY, true ); $iv = substr( hash( 'sha256', AUTH_SALT ), 0, 16 ); $decrypted = openssl_decrypt( $value, 'AES-256-CBC', $key, 0, $iv ); return false !== $decrypted ? $decrypted : ''; } /** * Update the Google feed status in the feeds table. * * @param string $status Feed status (complete, error). * @param string $error_message Error message if applicable. * * @return void */ private function update_feed_status( string $status, string $error_message ): void { $feed_table = $this->wpdb->prefix . 'aicommerce_feeds'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $this->wpdb->update( $feed_table, array( 'status' => $status, 'error_message' => $error_message, ), array( 'feed_type' => 'google' ), array( '%s', '%s' ), array( '%s' ) ); } }