(string) ( $state['site_id'] ?? '' ), 'secret' => (string) ( $state['secret'] ?? '' ), 'plan' => (string) ( $state['plan'] ?? '' ), 'usage' => is_array( $state['usage'] ?? null ) ? $state['usage'] : array(), 'error' => (string) ( $state['error'] ?? '' ), 'offset' => (int) ( $state['offset'] ?? 0 ), ); } /** * Changes parts of the stored state. * * @param array $changes Keys to change. */ private static function save( array $changes ) { update_option( self::OPTION, array_merge( self::state(), $changes ), false ); } /** * Whether the owner turned the assistant on and the site is signed up. * * @return bool */ public static function enabled() { return (bool) Settings::get( 'ai_enabled' ) && '' !== self::state()['site_id']; } /** * Signs up or leaves when the setting changes. * * @param mixed $old_value Settings before. * @param mixed $value Settings after. */ public static function settings_saved( $old_value, $value ) { $was = ! empty( $old_value['ai_enabled'] ); $now = ! empty( $value['ai_enabled'] ); if ( $now && ( ! $was || '' === self::state()['site_id'] ) ) { $result = self::connect(); if ( ! is_wp_error( $result ) ) { self::backfill(); } } elseif ( ! $now && $was ) { self::disconnect(); } } /** * The first save of the settings, e.g. from the setup assistant. * * @param string $option Option name. * @param mixed $value Settings. */ public static function settings_added( $option, $value ) { unset( $option ); self::settings_saved( array(), $value ); } /** * Route the service fetches the proof from while the site signs up. */ public function routes() { register_rest_route( 'dealroot/v1', '/ai-proof', array( 'methods' => 'GET', 'callback' => array( $this, 'proof' ), 'permission_callback' => array( $this, 'signing_up' ), ) ); } /** * Permission of the route: it answers only for the few minutes of a sign-up this site * started, when Dealroot Cloud fetches the proof. * * @return true|\WP_Error */ public function signing_up() { $proof = get_transient( self::PROOF ); if ( ! is_string( $proof ) || '' === $proof ) { return new \WP_Error( 'dealroot_no_proof', __( 'No sign-up is in progress.', 'dealroot' ), array( 'status' => 404 ) ); } return true; } /** * The proof of a sign-up in progress. It is only kept for a few minutes and is useless to * anyone else: the service compares it with the hash this site sent. * * @return \WP_REST_Response */ public function proof() { $proof = get_transient( self::PROOF ); if ( ! is_string( $proof ) || '' === $proof ) { return new \WP_REST_Response( array( 'code' => 'no_proof' ), 404 ); } return new \WP_REST_Response( array( 'proof' => $proof ), 200 ); } /** * Signs the site up. * * @return true|\WP_Error */ public static function connect() { $proof = wp_generate_password( 48, false ); set_transient( self::PROOF, $proof, 5 * MINUTE_IN_SECONDS ); $result = self::send( 'POST', '/v1/ai/register', array( 'home' => home_url( '/' ), 'proof_hash' => hash( 'sha256', $proof ), 'version' => DEALROOT_VERSION, ), false, 30 ); delete_transient( self::PROOF ); if ( is_wp_error( $result ) ) { self::save( array( 'error' => self::message( $result ) ) ); return $result; } self::save( array( 'site_id' => sanitize_key( (string) ( $result['site_id'] ?? '' ) ), 'secret' => (string) ( $result['secret'] ?? '' ), 'plan' => sanitize_key( (string) ( $result['plan'] ?? 'free' ) ), 'error' => '', ) ); return true; } /** * Leaves the assistant; the service forgets the site. */ public static function disconnect() { if ( '' !== self::state()['site_id'] ) { self::send( 'DELETE', '/v1/ai/site', null, true, 15 ); } delete_option( self::OPTION ); wp_unschedule_hook( self::ANALYZE_HOOK ); wp_unschedule_hook( self::CONFIRM_HOOK ); } /** * Queues a new lead for reading, a few seconds after it was saved. * * @param int $lead_id Lead ID. * @param array $data Stored values. */ public function queue( $lead_id, $data = array() ) { // Orders are read from their products at once, also without the assistant. if ( in_array( (string) ( $data['form_plugin'] ?? '' ), array( 'order', 'woocommerce' ), true ) ) { $lead = Leads::get( (int) $lead_id ); if ( $lead ) { self::store( (int) $lead_id, self::order_reading( $lead ) ); } if ( self::enabled() ) { /** This action is documented in analyze(). */ do_action( 'dealroot_ai_analyzed', (int) $lead_id, array( 'order' => true ) ); } return; } if ( ! self::enabled() || 'manual' === ( $data['form_plugin'] ?? '' ) ) { return; } wp_schedule_single_event( time() + 5, self::ANALYZE_HOOK, array( (int) $lead_id, 1 ) ); } /** * Queues the latest leads after the assistant was turned on, so the list is not empty of * summaries. They are read one after the other. */ public static function backfill() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE ai_state = '' AND status <> 'spam' AND form_plugin <> 'manual' ORDER BY id DESC LIMIT 20", Installer::table() ) ); foreach ( array_values( (array) $ids ) as $index => $id ) { // These were announced before; their summary is no reason for a second message. set_transient( 'dealroot_notified_' . (int) $id, 1, DAY_IN_SECONDS ); wp_schedule_single_event( time() + 10 + $index * 40, self::ANALYZE_HOOK, array( (int) $id, 1 ) ); } } /** * Reads one lead and stores what the assistant found. * * @param int $lead_id Lead ID. * @param int $attempt Number of the try. * @return bool */ public static function analyze( $lead_id, $attempt = 1 ) { $lead = Leads::get( (int) $lead_id ); if ( ! $lead || ! self::enabled() ) { return false; } // An order says what was bought; the assistant would only guess around it. So does a // checkout once its order brought the products. if ( in_array( (string) $lead->form_plugin, array( 'order', 'woocommerce' ), true ) || ( Orders::is_checkout( (string) $lead->form_name ) && false !== strpos( (string) $lead->fields, '"name":"items"' ) ) ) { self::store( $lead->id, self::order_reading( $lead ) ); /** This action is documented below. */ do_action( 'dealroot_ai_analyzed', (int) $lead->id, array( 'order' => true ) ); return true; } $text = self::text( $lead ); // Already sorted out as spam, e.g. by Dealroot Pro: nothing to read. if ( mb_strlen( $text ) < 3 || 'spam' === $lead->status ) { self::store( $lead->id, array( 'ai_state' => 'skipped' ) ); return false; } // The plainest spam is sorted out here, without the assistant. if ( Spam_Check::is_spam( $lead, $text ) ) { self::store( $lead->id, array( 'ai_kind' => 'spam', 'ai_state' => 'filtered', ) ); if ( Settings::get( 'ai_spam' ) && 'new' === $lead->status ) { Leads::update( $lead->id, array( 'status' => 'spam' ) ); } return false; } // The same message sent twice is read once. $copy = Spam_Check::earlier_copy( $lead ); if ( $copy ) { $columns = array( 'ai_state' => 'done' ); foreach ( array( 'ai_summary', 'ai_topics', 'ai_urgent', 'ai_kind', 'ai_budget', 'ai_currency', 'ai_details', 'ai_lang', 'ai_abusive' ) as $column ) { $columns[ $column ] = $copy->$column; } self::store( $lead->id, $columns ); if ( 'spam' === $copy->ai_kind && Settings::get( 'ai_spam' ) && 'new' === $lead->status ) { Leads::update( $lead->id, array( 'status' => 'spam' ) ); } /** This action is documented below. */ do_action( 'dealroot_ai_analyzed', (int) $lead->id, array( 'copy_of' => (int) $copy->id ) ); return true; } $result = self::send( 'POST', '/v1/ai/analyze', array( 'text' => $text, 'form' => (string) $lead->form_name, 'lang' => self::language(), 'topics' => self::known_topics(), ), true, 150 ); if ( is_wp_error( $result ) ) { $data = (array) $result->get_error_data(); // Busy or away for a moment: later, a little later each time. if ( ! empty( $data['retry'] ) && 'ai_quota' !== $result->get_error_code() && $attempt < self::MAX_TRIES ) { wp_schedule_single_event( time() + 60 * $attempt * $attempt, self::ANALYZE_HOOK, array( (int) $lead->id, (int) $attempt + 1 ) ); return false; } self::store( $lead->id, array( 'ai_state' => 'ai_quota' === $result->get_error_code() ? 'quota' : 'failed' ) ); return false; } self::remember_usage( $result ); $topics = self::clean_topics( array_map( static function ( $topic ) { return mb_substr( sanitize_text_field( (string) $topic ), 0, 40 ); }, (array) ( $result['topics'] ?? array() ) ) ); $kind = in_array( $result['kind'] ?? '', array( 'enquiry', 'order', 'job', 'spam', 'other' ), true ) ? $result['kind'] : 'other'; $budget = isset( $result['budget'] ) && is_numeric( $result['budget'] ) && (float) $result['budget'] > 0 ? round( (float) $result['budget'], 2 ) : null; $columns = array( 'ai_summary' => mb_substr( sanitize_text_field( (string) ( $result['summary'] ?? '' ) ), 0, 255 ), 'ai_topics' => mb_substr( implode( '|', array_slice( $topics, 0, 3 ) ), 0, 255 ), 'ai_urgent' => empty( $result['urgent'] ) ? 0 : 1, 'ai_kind' => $kind, 'ai_state' => 'done', 'ai_budget' => $budget, 'ai_currency' => null !== $budget ? self::currency( $result['currency'] ?? '' ) : '', 'ai_details' => self::clean_details( $result['details'] ?? array() ), 'ai_lang' => preg_match( '/^[a-z]{2}$/', (string) ( $result['language'] ?? '' ) ) ? (string) $result['language'] : '', 'ai_abusive' => empty( $result['abusive'] ) ? 0 : 1, ); // What the form text gave away first stays; the assistant only adds what it missed. if ( '' === (string) $lead->privacy_request && in_array( $result['privacy'] ?? '', array( 'delete', 'access' ), true ) ) { $columns['privacy_request'] = $result['privacy']; } // The budget is a first guess of the deal value until the owner enters one. if ( null !== $budget && 'spam' !== $kind && null === $lead->value && 'woocommerce' !== $lead->form_plugin ) { $columns['value'] = $budget; $columns['currency'] = $columns['ai_currency']; } self::store( $lead->id, $columns ); // Spam goes where the owner does not have to look at it; orders from the shop never do, and // neither does a request about personal data, which has to be answered. if ( 'spam' === $kind && Settings::get( 'ai_spam' ) && 'new' === $lead->status && 'woocommerce' !== $lead->form_plugin && ! isset( $columns['privacy_request'] ) && '' === (string) $lead->privacy_request ) { Leads::update( $lead->id, array( 'status' => 'spam' ) ); } if ( self::should_confirm( Leads::get( $lead->id ) ) ) { wp_schedule_single_event( time(), self::CONFIRM_HOOK, array( (int) $lead->id, 1 ) ); } /** * Fires after the assistant read a lead. * * @param int $lead_id Lead ID. * @param array $result Summary, topics, urgent and kind. */ do_action( 'dealroot_ai_analyzed', (int) $lead->id, $result ); return true; } /** * Whether a new lead gets a confirmation by email: the owner turned it on, it is a real * enquiry with an email address, it arrived within the last hour, and the same address did not * get one in the last day. * * @param object|null $lead Lead. * @return bool */ public static function should_confirm( $lead ) { global $wpdb; // While the owner is away, everyone gets a confirmation that says when the answer comes. // Orders are confirmed by the shop itself, so only enquiries get a confirmation. if ( ! $lead || ! ( Settings::get( 'ai_confirm' ) || Away::active() ) || 'enquiry' !== (string) $lead->ai_kind ) { return false; } // Insults and requests about personal data are for the owner to answer. if ( ! empty( $lead->ai_abusive ) || '' !== (string) $lead->privacy_request ) { return false; } // Shops send their own order mails; leads entered by hand were answered already. if ( 'new' !== $lead->status || in_array( $lead->form_plugin, array( 'woocommerce', 'order', 'manual' ), true ) || ! is_email( $lead->email ) ) { return false; } if ( strtotime( $lead->created_at . ' UTC' ) < time() - HOUR_IN_SECONDS || ! empty( $lead->ai_confirmed_at ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $recent = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE email = %s AND id <> %d AND ai_confirmed_at >= %s', Installer::table(), $lead->email, $lead->id, gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ); return 0 === $recent; } /** * Writes the confirmation of a new enquiry and sends it to the person. * * @param int $lead_id Lead ID. * @param int $attempt Number of the try. * @return bool */ public static function confirm( $lead_id, $attempt = 1 ) { $lead = Leads::get( (int) $lead_id ); if ( ! self::enabled() || ! self::should_confirm( $lead ) ) { return false; } /** * Filters what the AI assistant gets to write the confirmation, e.g. free times to offer. * * @param array $request Request. * @param object $lead Lead. */ $request = (array) apply_filters( 'dealroot_confirm_request', array( 'text' => self::text( $lead ), 'name' => trim( (string) $lead->name ), 'form' => (string) $lead->form_name, 'lang' => self::language(), 'business' => self::business(), 'formal' => true, 'history' => Lead_Insights::customer_hint( $lead ), 'reply_lang' => (string) ( $lead->ai_lang ?? '' ), 'away' => Away::active() ? Away::until_text() : '', ), $lead ); $result = self::send( 'POST', '/v1/ai/confirm', $request, true, 150 ); if ( is_wp_error( $result ) ) { $data = (array) $result->get_error_data(); // A confirmation is only worth it while the enquiry is fresh. if ( ! empty( $data['retry'] ) && 'ai_quota' !== $result->get_error_code() && $attempt < 3 ) { wp_schedule_single_event( time() + 60 * $attempt, self::CONFIRM_HOOK, array( (int) $lead->id, (int) $attempt + 1 ) ); } return false; } self::remember_usage( $result ); $subject = sanitize_text_field( (string) ( $result['subject'] ?? '' ) ); $body = sanitize_textarea_field( (string) ( $result['body'] ?? '' ) ); // The note of the owner, e.g. whom to call while away, goes in word for word, before the closing. $note = Away::active() ? Away::get()['note'] : ''; if ( '' !== $note && '' !== $body ) { $end = strrpos( $body, "\n\n" ); $body = false === $end ? $body . "\n\n" . $note : substr( $body, 0, $end ) . "\n\n" . $note . substr( $body, $end ); } if ( '' === $subject || '' === $body ) { return false; } $sent = self::mail( $lead->email, $subject, $body, true ); if ( ! $sent ) { return false; } Messages::add( (int) $lead->id, 'out', 'confirm', $body, array( 'subject' => $subject, 'user_id' => 0, ) ); self::store( $lead->id, array( 'ai_confirmed_at' => current_time( 'mysql', true ), 'ai_confirmation' => mb_substr( $subject . "\n\n" . $body, 0, 4000 ), ) ); return true; } /** * Sends an email to a person who sent an enquiry, in the name of the business, with replies * going to the address the owner reads. * * @param string $to Email address. * @param string $subject Subject. * @param string $body Text. * @param bool $automatic Whether it is sent without a person, like the confirmation. * @return bool */ public static function mail( $to, $subject, $body, $automatic = false ) { return Mail::customer( $to, $subject, $body, array( 'automatic' => (bool) $automatic ) ); } /** * The confirmation a lead got: subject and text. * * @param object $lead Lead. * @return array{subject: string, body: string}|null */ public static function confirmation( $lead ) { if ( empty( $lead->ai_confirmed_at ) || '' === (string) $lead->ai_confirmation ) { return null; } $parts = explode( "\n\n", (string) $lead->ai_confirmation, 2 ); return array( 'subject' => $parts[0], 'body' => $parts[1] ?? '', ); } /** * A first draft of the answer to a lead. * * @param object $lead Lead. * @param string $hint What the owner wants to say, optional. * @param \WP_User $sender Who answers; the current user when not given. * @param bool $batch One of the drafts for all new enquiries at once, with its own allowance. * @return string|\WP_Error */ public static function draft( $lead, $hint = '', $sender = null, $batch = false ) { if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } if ( ! empty( $lead->ai_abusive ) ) { return new \WP_Error( 'ai_abusive', self::abusive_note() ); } $result = self::send( 'POST', '/v1/ai/draft', self::draft_request( $lead, $hint, $sender ) + ( $batch ? array( 'batch' => true ) : array() ), true, 150 ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ) ); } self::remember_usage( $result ); return sanitize_textarea_field( (string) ( $result['draft'] ?? '' ) ); } /** * Asks the service for something other parts write with the assistant, e.g. Dealroot Pro. * * @param string $path Path under /v1/ai/, e.g. "nudge". * @param array $payload Request. * @param int $timeout Seconds. * @return array|\WP_Error Answer, or an error with a readable message. */ public static function ask( $path, array $payload, $timeout = 150 ) { if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } $result = self::send( 'POST', '/v1/ai/' . sanitize_key( $path ), $payload + array( 'lang' => self::language() ), true, $timeout ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ), $result->get_error_data() ); } self::remember_usage( $result ); return $result; } /** * A ticket the browser uses to have the draft written word by word, straight from the service. * The secret of the site stays on the server. * * @param object $lead Lead. * @param string $hint What the owner wants to say, optional. * @return array{ticket: string, url: string}|\WP_Error */ public static function ticket( $lead, $hint = '' ) { if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } if ( ! empty( $lead->ai_abusive ) ) { return new \WP_Error( 'ai_abusive', self::abusive_note() ); } $result = self::send( 'POST', '/v1/ai/draft/ticket', self::draft_request( $lead, $hint ) + array( 'origin' => self::origin( admin_url() ) ), true, 20 ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ) ); } $url = esc_url_raw( (string) ( $result['url'] ?? '' ) ); // The browser only ever talks to the service itself. if ( 0 !== strpos( $url, self::url() . '/' ) || empty( $result['ticket'] ) ) { return new \WP_Error( 'ai_failed', self::message( new \WP_Error( 'ai_failed' ) ) ); } return array( 'ticket' => (string) $result['ticket'], 'url' => $url, ); } /** * Providers a Pro site can bring its own key for, with a model to start with. */ const PROVIDERS = array( 'openai' => array( 'OpenAI', 'gpt-4.1-mini', true ), 'gemini' => array( 'Google Gemini', 'gemini-2.5-flash', true ), 'anthropic' => array( 'Anthropic Claude', 'claude-haiku-4-5', true ), 'mistral' => array( 'Mistral', 'mistral-small-latest', false ), ); /** * Lets the site use its own key of a language model provider; it then pays the provider and has * no allowance. The key goes to the service of Dealroot once and is not kept on the site. * * @param string $provider Provider key. * @param string $model Model, or "" for the one to start with. * @param string $key API key. * @return array|\WP_Error The own model. */ public static function save_key( $provider, $model, $key ) { if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } $result = self::send( 'POST', '/v1/ai/key', array( 'provider' => $provider, 'model' => $model, 'api_key' => $key, ), true, 60 ); if ( is_wp_error( $result ) ) { $messages = array( 'pro_only' => __( 'Your own AI model needs Dealroot Pro with an active license.', 'dealroot' ), 'key_failed' => __( 'The provider did not accept this key or model. Check both, and the credit of your account there.', 'dealroot' ), ); return new \WP_Error( $result->get_error_code(), $messages[ $result->get_error_code() ] ?? self::message( $result ) ); } self::remember_usage( $result ); return (array) ( $result['own'] ?? array() ); } /** * Goes back to the model of Dealroot. * * @return true|\WP_Error */ public static function remove_key() { $result = self::send( 'DELETE', '/v1/ai/key', null, true, 30 ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ) ); } self::remember_usage( $result ); return true; } /** * A voice message of the owner, written down. * * @param string $audio Bytes of the recording, e.g. Ogg from Telegram or WebM from a browser. * @param string $lang Language spoken, e.g. "de"; the one of the site when empty. * @return string|\WP_Error */ public static function transcribe( $audio, $lang = '' ) { if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } $result = self::send( 'POST', '/v1/ai/transcribe', (string) $audio, true, 150, false, array( 'X-Dealroot-Lang' => '' !== $lang ? $lang : self::language() ) ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ), $result->get_error_data() ); } self::remember_usage( $result ); return sanitize_textarea_field( (string) ( $result['text'] ?? '' ) ); } /** * Why no answer is written for an insulting message. * * @return string */ public static function abusive_note() { return __( 'This message insults or threatens. The AI assistant writes no answer to it – you decide whether and how to answer.', 'dealroot' ); } /** * The enquiry in another language, by default the one of the current user. The last * translation is kept with the lead, so it is written only once. * * @param object $lead Lead. * @param string $lang Language code, e.g. "de". * @return string|\WP_Error */ public static function translate( $lead, $lang = '' ) { $lang = '' !== $lang ? $lang : self::user_language(); $kept = self::translation( $lead, $lang ); if ( '' !== $kept ) { return $kept; } if ( ! self::enabled() ) { return new \WP_Error( 'ai_off', __( 'Turn on the AI assistant in the settings first.', 'dealroot' ) ); } $result = self::send( 'POST', '/v1/ai/translate', array( 'text' => self::text( $lead ), 'lang' => $lang, ), true, 150 ); if ( is_wp_error( $result ) ) { return new \WP_Error( $result->get_error_code(), self::message( $result ) ); } self::remember_usage( $result ); $text = sanitize_textarea_field( (string) ( $result['translation'] ?? '' ) ); if ( '' !== $text ) { self::store( $lead->id, array( 'ai_translation' => $lang . "\n" . $text ) ); } return $text; } /** * The kept translation of a lead into a language, or "". * * @param object $lead Lead. * @param string $lang Language code. * @return string */ public static function translation( $lead, $lang = '' ) { $lang = '' !== $lang ? $lang : self::user_language(); $parts = explode( "\n", (string) ( $lead->ai_translation ?? '' ), 2 ); return 2 === count( $parts ) && $parts[0] === $lang ? $parts[1] : ''; } /** * Whether an enquiry is written in another language than the one given. * * @param object $lead Lead. * @param string $lang Language code; the one of the current user when empty. * @return bool */ public static function is_foreign( $lead, $lang = '' ) { $written = (string) ( $lead->ai_lang ?? '' ); $lang = '' !== $lang ? $lang : self::user_language(); return '' !== $written && $lang !== $written; } /** * Language of the current user, e.g. "de". * * @return string */ public static function user_language() { return strtolower( substr( get_user_locale(), 0, 2 ) ); } /** * Name of a language in the language of the screen. * * @param string $code Language code. * @return string */ public static function language_name( $code ) { $names = array( 'de' => __( 'German', 'dealroot' ), 'en' => __( 'English', 'dealroot' ), 'fr' => __( 'French', 'dealroot' ), 'es' => __( 'Spanish', 'dealroot' ), 'it' => __( 'Italian', 'dealroot' ), 'nl' => __( 'Dutch', 'dealroot' ), 'pl' => __( 'Polish', 'dealroot' ), 'uk' => __( 'Ukrainian', 'dealroot' ), 'ru' => __( 'Russian', 'dealroot' ), 'pt' => __( 'Portuguese', 'dealroot' ), 'cs' => __( 'Czech', 'dealroot' ), 'sv' => __( 'Swedish', 'dealroot' ), 'da' => __( 'Danish', 'dealroot' ), 'tr' => __( 'Turkish', 'dealroot' ), 'ar' => __( 'Arabic', 'dealroot' ), 'ro' => __( 'Romanian', 'dealroot' ), 'el' => __( 'Greek', 'dealroot' ), 'hu' => __( 'Hungarian', 'dealroot' ), 'bg' => __( 'Bulgarian', 'dealroot' ), 'hr' => __( 'Croatian', 'dealroot' ), 'sr' => __( 'Serbian', 'dealroot' ), 'fa' => __( 'Persian', 'dealroot' ), 'zh' => __( 'Chinese', 'dealroot' ), 'vi' => __( 'Vietnamese', 'dealroot' ), ); return $names[ $code ] ?? strtoupper( $code ); } /** * What the service needs to write a draft. * * @param object $lead Lead. * @param string $hint What the owner wants to say. * @param \WP_User $sender Who answers. * @return array */ private static function draft_request( $lead, $hint = '', $sender = null ) { $sender = $sender instanceof \WP_User ? $sender : wp_get_current_user(); $examples = array(); // The replies the owner wrote show the tone. foreach ( array_slice( Replies::all( $lead ), 0, 2 ) as $reply ) { $examples[] = Replies::fill( $reply['body'], $lead ); } $text = self::text( $lead ); // Orders, calls written down and form fields without a message still get an answer. if ( '' === trim( $text ) ) { $form = '' !== (string) $lead->form_name ? (string) $lead->form_name : 'the website'; $text = sprintf( '(No message was written. The enquiry came through %s.)', $form ); } $request = array( 'text' => $text, 'name' => trim( (string) $lead->name ), 'form' => (string) $lead->form_name, 'lang' => self::language(), 'business' => self::business(), 'sender' => $sender && $sender->exists() ? $sender->display_name : '', 'formal' => true, 'examples' => $examples, 'sent' => Reply_Style::examples( (string) ( $lead->ai_lang ?? '' ) ), 'hint' => mb_substr( sanitize_text_field( $hint ), 0, 500 ), 'history' => Lead_Insights::customer_hint( $lead ), 'reply_lang' => (string) ( $lead->ai_lang ?? '' ), ); /** * Filters what the AI assistant gets to write a draft, e.g. that the answer comes late. * * @param array $request Request. * @param object $lead Lead. */ return (array) apply_filters( 'dealroot_draft_request', $request, $lead ); } /** * Name of the business, as the site calls itself. * * @return string */ public static function business() { return wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); } /** * Origin of an address: scheme, host and port. * * @param string $url Address. * @return string */ private static function origin( $url ) { $parts = wp_parse_url( $url ); return ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ); } /** * What the assistant reads of a lead: its fields with their labels, without email addresses, * phone numbers, consent boxes and technical fields. * * @param object $lead Lead. * @return string */ public static function text( $lead ) { $lines = array(); $fields = json_decode( (string) $lead->fields, true ); foreach ( (array) $fields as $field ) { $value = trim( (string) ( $field['value'] ?? '' ) ); $label = trim( (string) ( '' !== (string) ( $field['label'] ?? '' ) ? $field['label'] : ( $field['name'] ?? '' ) ) ); $about = strtolower( $label . ' ' . ( $field['name'] ?? '' ) ); if ( '' === $value || in_array( $field['type'] ?? '', array( 'email', 'tel', 'hidden', 'password', 'file' ), true ) ) { continue; } if ( preg_match( '/(e-?mail|phone|telefon|tel\b|mobil|handy|whatsapp|consent|datenschutz|privacy|gdpr|dsgvo|agb|terms|newsletter|captcha|token|nonce|referrer|utm_|gclid)/', $about ) ) { continue; } if ( $value === $lead->name ) { continue; } $lines[] = ( '' !== $label ? $label . ': ' : '' ) . mb_substr( $value, 0, 2000 ); } return mb_substr( implode( "\n", $lines ), 0, 6000 ); } /** * What an order is about, from its products: they are its topics, and the summary lists them. * * @param object $lead Lead of the order. * @return array Columns. */ public static function order_reading( $lead ) { $products = array(); foreach ( (array) json_decode( (string) $lead->fields, true ) as $field ) { if ( in_array( (string) ( $field['name'] ?? '' ), array( 'items', 'products' ), true ) ) { foreach ( preg_split( '/\R|,\s*/u', (string) ( $field['value'] ?? '' ) ) as $line ) { // "2 × Rosen", "Rosen × 2" and "Rosen (2)" are about Rosen. $name = trim( (string) preg_replace( array( '/^\d+\s*[×x]\s*/u', '/\s*[×x]\s*\d+$/u', '/\s*\(\d+\)$/u' ), '', $line ) ); if ( '' !== $name ) { $products[] = mb_substr( $name, 0, 40 ); } } } } $products = array_values( array_unique( $products ) ); return array( 'ai_state' => 'done', 'ai_kind' => 'order', 'ai_lang' => '' !== (string) ( $lead->ai_lang ?? '' ) ? (string) $lead->ai_lang : self::guess_language( self::fields_text( $lead ) ), /* translators: %s: products */ 'ai_summary' => $products ? mb_substr( self::in_locale( self::team_locale(), fn() => sprintf( __( 'Order: %s', 'dealroot' ), implode( ', ', $products ) ) ), 0, 255 ) : '', 'ai_topics' => mb_substr( implode( '|', array_slice( $products, 0, 3 ) ), 0, 255 ), ); } /** * Language Dealroot writes to the team in, e.g. "de": summaries, topics, advice. * * @return string */ private static function language() { return strtolower( substr( self::team_locale(), 0, 2 ) ); } /** * Locale of the texts for the team: the one chosen in the settings, otherwise the language * of the profile of the site owner, otherwise the language of the site. * * @return string */ public static function team_locale() { $chosen = self::locale_for( (string) Settings::get( 'team_lang' ) ); if ( '' !== $chosen ) { return $chosen; } $owner = get_user_by( 'email', (string) get_option( 'admin_email' ) ); return $owner ? get_user_locale( $owner ) : get_locale(); } /** * Locale for texts to a customer: the language of the enquiry when Dealroot speaks it, * otherwise the language of the site. * * @param object|null $lead Lead. * @return string */ public static function customer_locale( $lead = null ) { $lang = $lead ? (string) ( $lead->ai_lang ?? '' ) : ''; $lang = '' !== $lang || ! $lead ? $lang : self::guess_language( self::fields_text( $lead ) ); $locale = self::locale_for( $lang ); return '' !== $locale ? $locale : get_locale(); } /** * Labels and values of the fields of a lead, as one text, e.g. to tell its language. * * @param object $lead Lead. * @return string */ private static function fields_text( $lead ) { $parts = array(); foreach ( (array) json_decode( (string) ( $lead->fields ?? '' ), true ) as $field ) { if ( is_array( $field ) && 'email' !== ( $field['type'] ?? '' ) ) { $parts[] = (string) ( $field['label'] ?? '' ) . ' ' . (string) ( $field['value'] ?? '' ); } } return implode( "\n", $parts ); } /** * Language of a short text, from its common words, for enquiries the assistant did not read, * e.g. orders from a checkout. Names and numbers say nothing, so a text needs a few words of * one language. * * @param string $text Text. * @return string Language code, or "" when it cannot be told. */ public static function guess_language( $text ) { $text = mb_strtolower( (string) $text ); if ( preg_match_all( '/[а-яёіїєґ]/u', $text ) > 20 ) { return preg_match( '/[іїєґ]/u', $text ) ? 'uk' : 'ru'; } $words = array( 'de' => array( 'und', 'der', 'die', 'das', 'ich', 'wir', 'mit', 'für', 'nicht', 'ist', 'bitte', 'gerne', 'vorname', 'nachname', 'straße', 'ort', 'gewünschtes', 'datum', 'uhr', 'ab', 'liefern', 'lieferung', 'abholung', 'hallo', 'danke', 'nachricht', 'anrede', 'telefon', 'firma', 'eine', 'einen', 'zu', 'auf' ), 'en' => array( 'the', 'and', 'you', 'for', 'with', 'please', 'would', 'like', 'we', 'our', 'your', 'first', 'last', 'name', 'message', 'hello', 'thanks', 'delivery', 'date', 'company', 'phone', 'is', 'to', 'of' ), 'fr' => array( 'et', 'le', 'la', 'les', 'je', 'nous', 'pour', 'avec', 'est', 'une', 'des', 'bonjour', 'merci', 'prénom', 'nom', 'téléphone', 'livraison', 'société', 'vous', 'du' ), 'es' => array( 'y', 'el', 'los', 'las', 'que', 'para', 'con', 'una', 'hola', 'gracias', 'nombre', 'apellido', 'teléfono', 'entrega', 'fecha', 'empresa', 'quiero', 'mensaje', 'del' ), 'it' => array( 'il', 'che', 'per', 'con', 'una', 'sono', 'grazie', 'buongiorno', 'cognome', 'telefono', 'consegna', 'azienda', 'vorrei', 'messaggio', 'della', 'del' ), 'nl' => array( 'en', 'het', 'een', 'ik', 'wij', 'voor', 'met', 'niet', 'graag', 'voornaam', 'achternaam', 'bedrijf', 'bericht', 'levering', 'datum', 'hallo', 'bedankt', 'van' ), 'pl' => array( 'i', 'w', 'na', 'nie', 'jest', 'się', 'dzień', 'dobry', 'proszę', 'imię', 'nazwisko', 'telefon', 'dostawa', 'firma', 'wiadomość', 'chciałbym', 'chciałabym', 'dla' ), ); preg_match_all( '/[\p{L}]+/u', $text, $found ); $counts = array_count_values( $found[0] ); $scores = array(); foreach ( $words as $lang => $list ) { $scores[ $lang ] = 0; foreach ( $list as $word ) { $scores[ $lang ] += $counts[ $word ] ?? 0; } } arsort( $scores ); $best = (string) key( $scores ); $values = array_values( $scores ); return $values[0] >= 2 && $values[0] > ( $values[1] ?? 0 ) ? $best : ''; } /** * Locale for a language code, when Dealroot is translated into it. * * @param string $lang Language, e.g. "de". * @return string Locale, e.g. "de_DE", or "". */ public static function locale_for( $lang ) { $lang = strtolower( substr( (string) $lang, 0, 2 ) ); if ( 'en' === $lang ) { return 'en_US'; } if ( ! preg_match( '/^[a-z]{2}$/', $lang ) ) { return ''; } if ( 0 === strpos( get_locale(), $lang ) ) { return get_locale(); } $found = glob( DEALROOT_DIR . 'languages/dealroot-' . $lang . '*.mo' ); return $found ? substr( basename( $found[0], '.mo' ), strlen( 'dealroot-' ) ) : ''; } /** * Runs a callback in another locale. * * @param string $locale Locale. * @param callable $callback Callback. * @return mixed What the callback gives back. */ public static function in_locale( $locale, $callback ) { static $loaded = ''; $current = '' !== $loaded ? $loaded : determine_locale(); if ( '' === $locale || $current === $locale ) { return call_user_func( $callback ); } // WordPress only switches to languages it has installed; the texts of Dealroot are // loaded on their own otherwise. $switched = '' === $loaded && switch_to_locale( $locale ); $before = $loaded; if ( ! $switched ) { self::load_texts( $locale ); $loaded = $locale; } $result = call_user_func( $callback ); if ( $switched ) { restore_previous_locale(); } else { // Back to the texts loaded before, or, on the next text, to those of the screen. self::load_texts( $before ); $loaded = $before; } return $result; } /** * Loads the texts of Dealroot in a locale, or leaves them to be loaded for the screen. * * @param string $locale Locale, "" for the language of the screen. */ private static function load_texts( $locale ) { /** * Text domains of Dealroot with the folder of their translations. * * @param array $domains Folder by text domain. */ $domains = (array) apply_filters( 'dealroot_text_domains', array( 'dealroot' => DEALROOT_DIR . 'languages' ) ); remove_filter( 'override_load_textdomain', array( __CLASS__, 'keep_english' ), 10 ); foreach ( $domains as $domain => $folder ) { unload_textdomain( $domain, true ); $file = trailingslashit( $folder ) . $domain . '-' . $locale . '.mo'; if ( '' !== $locale && 'en_US' !== $locale && is_readable( $file ) ) { load_textdomain( $domain, $file, $locale ); } } if ( 'en_US' === $locale ) { // English is the text itself: nothing is loaded until the switch back. add_filter( 'override_load_textdomain', array( __CLASS__, 'keep_english' ), 10, 2 ); } } /** * Keeps the texts of Dealroot in English while a customer is written to in English. * * @param bool $override Whether loading is skipped. * @param string $domain Text domain. * @return bool */ public static function keep_english( $override, $domain ) { return 0 === strpos( (string) $domain, 'dealroot' ) ? true : $override; } /** * Topics the assistant gave before, most used first, so it keeps to the same words. * * @return string[] */ public static function known_topics() { return array_slice( self::clean_topics( array_keys( self::topic_counts( 365 ) ) ), 0, 40 ); } /** * Whether a topic is how the order reaches the customer or is paid, e.g. "Abholung", * "Delivery" or "Самовывоз", rather than what the person wants. Such words are options of * the form, not products or services, so they are left out of the topics on every site. * * @param string $topic Topic. * @return bool */ public static function is_logistics( $topic ) { return (bool) preg_match( '/^(selbst)?(abhol|liefer|versand|zustell|bezahl|zahlung|pick[\s-]?up|click\s*(&|and)\s*collect|delivery|shipping|payment|livraison|retrait|enl[eè]vement|paiement|recogida|entrega|env[ií]o|pago|ritiro|consegna|spedizion|pagament|afhal|bezorg|levering|verzend|betaling|odbi[oó]r|dostaw|wysy[lł]k|p[lł]atno|самовив|самовыв|доставк|оплат|відправ|отправк)/iu', trim( (string) $topic ) ); } /** * Topics without the ways of delivery and payment, without repeats. * * @param string[] $topics Topics. * @return string[] */ public static function clean_topics( array $topics ) { return array_values( array_unique( array_filter( array_map( 'strval', $topics ), static function ( $topic ) { return '' !== $topic && ! self::is_logistics( $topic ); } ) ) ); } /** * Leads per topic. * * @param int $days Days back. * @return array */ private static function topic_counts( $days ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_col( $wpdb->prepare( "SELECT ai_topics FROM %i WHERE ai_topics <> '' AND created_at >= %s ORDER BY id DESC LIMIT 2000", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); $counts = array(); foreach ( (array) $rows as $row ) { foreach ( explode( '|', (string) $row ) as $topic ) { if ( '' !== $topic ) { $counts[ $topic ] = ( $counts[ $topic ] ?? 0 ) + 1; } } } arsort( $counts ); return $counts; } /** * Leads, won deals and revenue per topic in a period, most leads first. * * @param string $from UTC start, '' for all time. * @param string $to UTC end, '' for now. * @return array, channels: array}> */ public static function topics( $from, $to ) { global $wpdb; list( $start, $end ) = Reports::sql_range( $from, $to ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ai_topics, status, value, currency, channel FROM %i WHERE ai_topics <> '' AND status <> 'spam' AND ai_kind NOT IN ('spam', 'job') AND created_at >= %s AND created_at < %s", Installer::table(), $start, $end ) ); $topics = array(); foreach ( (array) $rows as $row ) { foreach ( array_unique( array_filter( explode( '|', (string) $row->ai_topics ), 'strlen' ) ) as $topic ) { if ( ! isset( $topics[ $topic ] ) ) { $topics[ $topic ] = array( 'leads' => 0, 'won' => 0, 'revenue' => array(), 'channels' => array(), ); } ++$topics[ $topic ]['leads']; $topics[ $topic ]['channels'][ (string) $row->channel ] = ( $topics[ $topic ]['channels'][ (string) $row->channel ] ?? 0 ) + 1; if ( 'won' === $row->status ) { ++$topics[ $topic ]['won']; $currency = '' !== (string) $row->currency ? (string) $row->currency : (string) Settings::get( 'currency' ); $topics[ $topic ]['revenue'][ $currency ] = ( $topics[ $topic ]['revenue'][ $currency ] ?? 0 ) + (float) $row->value; } } } uasort( $topics, static function ( $a, $b ) { return $b['leads'] <=> $a['leads']; } ); return $topics; } /** * Currency the assistant read, or the one of the site. * * @param mixed $code ISO code. * @return string */ private static function currency( $code ) { $code = strtoupper( (string) $code ); return 1 === preg_match( '/^[A-Z]{3}$/', $code ) ? $code : (string) Settings::get( 'currency' ); } /** * Date, quantity and place of an enquiry, as stored. * * @param mixed $details Details from the service. * @return string JSON, '' when none was found. */ private static function clean_details( $details ) { $clean = array(); $lengths = array( 'when' => 60, 'quantity' => 40, 'place' => 60, ); foreach ( $lengths as $key => $length ) { $value = is_array( $details ) && is_scalar( $details[ $key ] ?? null ) ? trim( sanitize_text_field( (string) $details[ $key ] ) ) : ''; if ( '' !== $value ) { $clean[ $key ] = mb_substr( $value, 0, $length ); } } return $clean ? (string) wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) : ''; } /** * Date, quantity and place of a lead. * * @param object $lead Lead. * @return array */ public static function details( $lead ) { $details = json_decode( (string) ( $lead->ai_details ?? '' ), true ); return is_array( $details ) ? array_map( 'strval', array_filter( $details, 'is_scalar' ) ) : array(); } /** * Writes what the assistant found into the lead. * * @param int $lead_id Lead ID. * @param array $columns Columns. */ private static function store( $lead_id, array $columns ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( Installer::table(), $columns, array( 'id' => (int) $lead_id ) ); } /** * Keeps the plan and the allowance the service reported. * * @param array $result Answer of the service. */ private static function remember_usage( array $result ) { if ( isset( $result['usage'] ) && is_array( $result['usage'] ) ) { self::save( array( 'plan' => sanitize_key( (string) ( $result['usage']['plan'] ?? 'free' ) ), 'usage' => self::clean_usage( $result['usage'] ), 'error' => '', ) ); } } /** * Numbers of the allowance only. * * @param array $usage Raw usage. * @return array */ private static function clean_usage( array $usage ) { $clean = array( 'resets' => absint( $usage['resets'] ?? 0 ) ); foreach ( array( 'analyze', 'draft', 'voice', 'inbox' ) as $kind ) { $clean[ $kind ] = array( 'used' => absint( $usage[ $kind ]['used'] ?? 0 ), 'limit' => absint( $usage[ $kind ]['limit'] ?? 0 ), ); } $clean['slow'] = ! empty( $usage['slow'] ); $clean['own'] = is_array( $usage['own'] ?? null ) && isset( self::PROVIDERS[ $usage['own']['provider'] ?? '' ] ) ? array( 'provider' => (string) $usage['own']['provider'], 'model' => sanitize_text_field( (string) ( $usage['own']['model'] ?? '' ) ), ) : null; return $clean; } /** * How many fast answers are left this month, in one sentence, from what the service reported * last. "" with an own key, or before the service reported anything. * * Drafts, questions and ideas share one allowance; the summaries of enquiries have their own. * * @return string */ public static function fast_left() { $usage = self::state()['usage']; if ( ! empty( $usage['own'] ) || empty( $usage['draft']['limit'] ) ) { return ''; } $left = max( 0, (int) $usage['draft']['limit'] - (int) $usage['draft']['used'] ); $resets = wp_date( get_option( 'date_format' ), ! empty( $usage['resets'] ) ? (int) $usage['resets'] : strtotime( 'first day of next month' ) ); if ( ! $left ) { return ! empty( $usage['slow'] ) /* translators: %s: date */ ? sprintf( __( 'The fast answers of this month are used up; until %s a slower model answers.', 'dealroot' ), $resets ) /* translators: %s: date */ : sprintf( __( 'The answers of this month are used up. Renewed on %s.', 'dealroot' ), $resets ); } return sprintf( /* translators: 1: answers left, 2: answers a month, 3: date */ _n( '%1$s of %2$s fast answer left this month, renewed on %3$s.', '%1$s of %2$s fast answers left this month, renewed on %3$s.', $left, 'dealroot' ), number_format_i18n( $left ), number_format_i18n( (int) $usage['draft']['limit'] ), $resets ); } /** * Fetches the allowance, for the settings screen. * * @return array */ public static function refresh_usage() { $result = self::send( 'GET', '/v1/ai/usage', null, true, 10 ); if ( ! is_wp_error( $result ) ) { self::save( array( 'plan' => sanitize_key( (string) ( $result['plan'] ?? 'free' ) ), 'usage' => self::clean_usage( $result ), ) ); } return self::state()['usage']; } /** * Readable message of an error. * * @param \WP_Error $error Error. * @return string */ public static function message( \WP_Error $error ) { $messages = array( 'ai_quota' => __( 'The AI allowance of this month is used up. It is renewed on the first day of next month.', 'dealroot' ), 'ai_busy' => __( 'The AI assistant is busy right now. Please try again in a minute.', 'dealroot' ), 'ai_unavailable' => __( 'The AI assistant is not available right now. Please try again later.', 'dealroot' ), 'ai_failed' => __( 'The AI assistant could not answer this time. Please try again.', 'dealroot' ), 'proof_failed' => __( 'Your site could not be verified. The AI assistant needs a site that can be reached from the internet, not one on your own computer or behind a password.', 'dealroot' ), 'invalid_site' => __( 'Your site could not be verified. The AI assistant needs a site that can be reached from the internet, not one on your own computer or behind a password.', 'dealroot' ), 'rate_limited' => __( 'Too many attempts. Please try again in an hour.', 'dealroot' ), 'unreachable' => __( 'The AI assistant cannot be reached right now. Please try again later.', 'dealroot' ), 'audio_too_long' => __( 'Voice messages can be up to two minutes long.', 'dealroot' ), 'invalid_audio' => __( 'This voice message could not be read. Please record it again.', 'dealroot' ), 'voice_off' => __( 'Voice messages are not available yet. Please type your message instead.', 'dealroot' ), ); if ( 'voice_unavailable' === $error->get_error_code() ) { return $messages['voice_off']; } return $messages[ $error->get_error_code() ] ?? __( 'Something went wrong. Please reload the page and try again.', 'dealroot' ); } /** * Sends a request to the service, signed when the site is signed up. * * @param string $method HTTP method. * @param string $path Path. * @param array|string|null $payload JSON body, or raw bytes, e.g. of a voice message. * @param bool $signed Sign the request. * @param int $timeout Seconds. * @param bool $again Whether this is the second try after signing up again. * @param array $extra More headers. * @return array|\WP_Error */ private static function send( $method, $path, $payload, $signed, $timeout, $again = false, array $extra = array() ) { $raw = is_string( $payload ); $body = $raw ? $payload : ( null === $payload ? '' : (string) wp_json_encode( $payload, JSON_UNESCAPED_UNICODE ) ); $headers = array_merge( array( 'Accept' => 'application/json' ), $extra ); if ( '' !== $body ) { $headers['Content-Type'] = $raw ? 'application/octet-stream' : 'application/json'; } if ( $signed ) { $state = self::state(); $secret = self::decode( $state['secret'] ); if ( '' === $state['site_id'] || '' === $secret ) { return new \WP_Error( 'unknown_site', '' ); } $timestamp = (string) ( time() + $state['offset'] ); $nonce = rtrim( strtr( base64_encode( random_bytes( 18 ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- A random one-time value. $headers['X-Dealroot-Ai-Site'] = $state['site_id']; $headers['X-Dealroot-Timestamp'] = $timestamp; $headers['X-Dealroot-Nonce'] = $nonce; $headers['X-Dealroot-Signature'] = hash_hmac( 'sha256', implode( "\n", array( 'LW1-HMAC-SHA256', $method, $path, $timestamp, $nonce, hash( 'sha256', $body ) ) ), $secret ); } $response = wp_remote_request( self::url() . $path, array( 'method' => $method, 'headers' => $headers, 'body' => '' === $body ? null : $body, 'timeout' => $timeout, 'redirection' => 0, 'user-agent' => 'Dealroot/' . DEALROOT_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'unreachable', $response->get_error_message(), array( 'retry' => true ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $status >= 200 && $status < 300 && is_array( $data ) ) { return $data; } $error = is_array( $data ) && is_array( $data['error'] ?? null ) ? $data['error'] : array(); $code = sanitize_key( (string) ( $error['code'] ?? 'http_' . $status ) ); // The clock of this server is off: keep the difference and try once more. if ( 'clock_skew' === $code && ! $again && isset( $error['server_time'] ) ) { self::save( array( 'offset' => (int) $error['server_time'] - time() ) ); return self::send( $method, $path, $payload, $signed, $timeout, true, $extra ); } // The service forgot the site, e.g. after a long pause: sign up again once. if ( $signed && ! $again && in_array( $code, array( 'unknown_site', 'bad_signature' ), true ) && ! is_wp_error( self::connect() ) ) { return self::send( $method, $path, $payload, $signed, $timeout, true, $extra ); } return new \WP_Error( $code, sanitize_text_field( (string) ( $error['message'] ?? '' ) ), array( 'status' => $status, 'retry' => ! empty( $error['retry'] ) || $status >= 500, ) ); } /** * Raw secret from its base64url form. * * @param string $encoded Encoded secret. * @return string */ private static function decode( $encoded ) { $raw = base64_decode( strtr( (string) $encoded, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- The secret is sent base64url-encoded. return false === $raw ? '' : $raw; } }