1 ), $args ), admin_url( 'admin.php?page=' . Admin\Admin::LEADS_PAGE ) ); } /** * The sets of enquiries to go through: the new ones, and what other parts add, e.g. Pro. * * @return array */ public static function sets() { $sets = array( 'new' => array( 'title' => __( 'Go through new enquiries', 'dealroot' ), 'description' => __( 'A draft for each new enquiry, written by the AI assistant in your style. Read it, change what does not fit, and send it.', 'dealroot' ), 'button' => static function ( $count ) { /* translators: %s: number of enquiries */ $label = _n( 'Go through %s new enquiry', 'Go through %s new enquiries', $count, 'dealroot' ); return $label; }, ), ); /** * Filters the sets of enquiries to go through with drafts ready. * * @param array $sets Key => title, description and the button: a function that gets the number of enquiries and returns the label, with %s for the number. */ return (array) apply_filters( 'dealroot_inbox_sets', $sets ); } /** * Enquiries of a set that wait for an answer and have no draft yet, oldest first. * * @param string $set Set; "new" for the new enquiries. * @return object[] */ public static function waiting( $set = 'new' ) { /** * Filters the enquiries of a set to write drafts for. * * @param object[] $leads Leads. * @param string $set Set. */ return array_slice( (array) apply_filters( 'dealroot_inbox_waiting', 'new' === $set ? self::fresh() : array(), $set ), 0, self::MAX ); } /** * New enquiries that wait for a first answer and have no draft yet, oldest first. * * @return object[] */ private static function fresh() { global $wpdb; return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status = 'new' AND contacted_at IS NULL AND form_plugin NOT IN ('manual', 'woocommerce') AND ai_kind NOT IN ('spam', 'job') AND ai_abusive = 0 AND privacy_request = '' AND email <> '' AND ( ai_draft IS NULL OR ai_draft = '' ) AND created_at >= %s ORDER BY created_at ASC LIMIT %d", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - self::DAYS * DAY_IN_SECONDS ), self::MAX ) ); } /** * New enquiries with a draft ready, oldest first. * * @return object[] */ public static function ready() { global $wpdb; return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status IN ('new', 'contacted', 'qualified', 'won') AND ai_draft IS NOT NULL AND ai_draft <> '' ORDER BY created_at ASC LIMIT %d", Installer::table(), self::MAX ) ); } /** * Where the drafts in the background are. * * @return array{queue: int[], total: int, done: int, failed: int, error: string, user: int, set: string} */ public static function state() { $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); return array( 'queue' => array_map( 'absint', (array) ( $state['queue'] ?? array() ) ), 'total' => (int) ( $state['total'] ?? 0 ), 'done' => (int) ( $state['done'] ?? 0 ), 'failed' => (int) ( $state['failed'] ?? 0 ), 'error' => (string) ( $state['error'] ?? '' ), 'user' => (int) ( $state['user'] ?? 0 ), 'set' => (string) ( $state['set'] ?? 'new' ), ); } /** * Sorts out spam and starts writing the drafts. * * @param int $user_id Who answers. * @param string $set Set of enquiries. * @return int Drafts to write. */ public static function start( $user_id, $set = 'new' ) { global $wpdb; // What the assistant read as spam goes where it belongs first. $spam = 'new' !== $set ? array() : $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE status = 'new' AND ai_kind = 'spam' AND created_at >= %s", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - self::DAYS * DAY_IN_SECONDS ) ) ); foreach ( $spam as $id ) { Leads::update( (int) $id, array( 'status' => 'spam' ) ); } $queue = array_map( static function ( $lead ) { return (int) $lead->id; }, self::waiting( $set ) ); update_option( self::OPTION, array( 'queue' => $queue, 'total' => count( $queue ), 'done' => 0, 'failed' => 0, 'error' => '', 'user' => (int) $user_id, 'set' => $set, 'spam' => count( $spam ), ), false ); if ( $queue && ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time(), self::HOOK ); } return count( $queue ); } /** * Writes the next few drafts, and schedules the rest. */ public static function work() { $state = self::state(); $user = get_user_by( 'id', $state['user'] ); $begin = microtime( true ); $count = 0; while ( $state['queue'] && $count < self::PER_RUN && microtime( true ) - $begin < 40 ) { $lead = Leads::get( array_shift( $state['queue'] ) ); ++$count; if ( ! $lead || ! in_array( $lead->status, array( 'new', 'contacted', 'qualified', 'won' ), true ) ) { --$state['total']; continue; } $draft = Ai::draft( $lead, '', $user ? $user : null, true ); if ( is_wp_error( $draft ) ) { ++$state['failed']; // Without allowance or service, the others would fail the same way. if ( in_array( $draft->get_error_code(), array( 'ai_quota', 'ai_off', 'ai_unavailable' ), true ) ) { $state['error'] = $draft->get_error_message(); $state['queue'] = array(); } continue; } if ( '' !== trim( $draft ) ) { self::store_draft( (int) $lead->id, $draft ); ++$state['done']; } else { ++$state['failed']; } } update_option( self::OPTION, array_merge( (array) get_option( self::OPTION, array() ), $state ), false ); if ( $state['queue'] && ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time(), self::HOOK ); } } /** * Keeps the draft of a lead, or removes it. * * @param int $lead_id Lead ID. * @param string|null $draft Text, or null to remove it. */ public static function store_draft( $lead_id, $draft ) { global $wpdb; $wpdb->update( Installer::table(), array( 'ai_draft' => null === $draft ? null : sanitize_textarea_field( $draft ), 'ai_draft_at' => null === $draft ? null : current_time( 'mysql', true ), ), array( 'id' => (int) $lead_id ) ); } /** * A lead answered in another way, or closed, needs no draft any more. * * @param int $lead_id Lead ID. * @param string $status New status. * @param string $old Status before. */ public static function status_changed( $lead_id, $status, $old = '' ) { if ( in_array( $status, array( 'won', 'lost', 'spam' ), true ) || ( 'new' === $old && 'new' !== $status ) ) { self::store_draft( (int) $lead_id, null ); } } /** * Sends one answer by email. * * @param object $lead Lead. * @param string $subject Subject. * @param string $text Text. * @return true|\WP_Error */ public static function send( $lead, $subject, $text ) { $text = trim( (string) $text ); if ( '' === $text ) { return new \WP_Error( 'dealroot_empty', __( 'The answer is empty.', 'dealroot' ) ); } if ( ! is_email( $lead->email ) ) { return new \WP_Error( 'dealroot_no_email', __( 'This lead has no email address.', 'dealroot' ) ); } $subject = trim( (string) $subject ); $subject = '' !== $subject ? $subject : __( 'Your enquiry', 'dealroot' ); if ( ! Ai::mail( $lead->email, $subject, $text ) ) { return new \WP_Error( 'dealroot_mail', __( 'The email could not be sent. Check the email settings of your site.', 'dealroot' ) ); } Messages::add( (int) $lead->id, 'out', 'email', $text, array( 'subject' => $subject ) ); self::store_draft( (int) $lead->id, null ); if ( 'new' === $lead->status ) { Impact::answered( (int) $lead->id, 'ai' ); Leads::update( (int) $lead->id, array( 'status' => 'contacted' ) ); } else { // A follow-up: the lead was touched again. Leads::update( (int) $lead->id, array() ); } /** * Fires after an answer to a lead was sent through Dealroot. * * @param int $lead_id Lead ID. * @param string $text Text sent. */ do_action( 'dealroot_answer_sent', (int) $lead->id, $text ); return true; } /** * Starts from the button. */ public static function handle_start() { check_admin_referer( 'dealroot_inbox' ); if ( ! current_user_can( Admin\Admin::capability() ) ) { wp_die( esc_html__( 'You are not allowed to edit leads.', 'dealroot' ), 403 ); } $set = isset( $_POST['set'] ) ? sanitize_key( wp_unslash( $_POST['set'] ) ) : 'new'; $set = isset( self::sets()[ $set ] ) ? $set : 'new'; self::start( get_current_user_id(), $set ); // The first drafts are written right away, the rest while the screen waits. self::work(); wp_safe_redirect( self::url( 'new' !== $set ? array( 'set' => $set ) : array() ) ); exit; } /** * Sends the answer of one lead. */ public static function handle_send() { $lead_id = isset( $_POST['lead_id'] ) ? absint( $_POST['lead_id'] ) : 0; check_admin_referer( 'dealroot_inbox_' . $lead_id ); if ( ! current_user_can( Admin\Admin::capability() ) ) { wp_die( esc_html__( 'You are not allowed to edit leads.', 'dealroot' ), 403 ); } $lead = Leads::get( $lead_id ); $sent = $lead ? self::send( $lead, isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : '', isset( $_POST['text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['text'] ) ) : '' ) : new \WP_Error( 'dealroot_gone', __( 'This lead was deleted.', 'dealroot' ) ); if ( is_wp_error( $sent ) ) { set_transient( 'dealroot_inbox_error_' . get_current_user_id(), $sent->get_error_message(), MINUTE_IN_SECONDS ); } wp_safe_redirect( self::url( is_wp_error( $sent ) ? array() : array( 'sent' => $lead_id ) ) ); exit; } /** * Leaves one lead for later, without its draft. */ public static function handle_skip() { $lead_id = isset( $_POST['lead_id'] ) ? absint( $_POST['lead_id'] ) : 0; check_admin_referer( 'dealroot_inbox_' . $lead_id ); if ( ! current_user_can( Admin\Admin::capability() ) ) { wp_die( esc_html__( 'You are not allowed to edit leads.', 'dealroot' ), 403 ); } self::store_draft( $lead_id, null ); wp_safe_redirect( self::url() ); exit; } /** * The button that starts it. * * @param string $classes More classes. * @param string $set Set of enquiries. * @return string */ public static function button( $classes = '', $set = 'new' ) { $sets = self::sets(); $count = isset( $sets[ $set ] ) && Ai::enabled() ? count( self::waiting( $set ) ) : 0; if ( ! $count ) { return ''; } return sprintf( '
%4$s
', esc_url( admin_url( 'admin-post.php' ) ), esc_attr( $classes ), esc_attr( $set ), wp_nonce_field( 'dealroot_inbox', '_wpnonce', true, false ), esc_html( sprintf( self::label( $sets[ $set ]['button'], $count ), number_format_i18n( $count ) ) ) ); } /** * The label of a button for a number of enquiries. * * @param callable|array $button A function that gets the number, or a plural made with _n_noop() by an older add-on. * @param int $count Number of enquiries. * @return string With %s for the number. */ private static function label( $button, $count ) { if ( is_callable( $button ) ) { return (string) call_user_func( $button, $count ); } // An older add-on: its plural, untranslated, until it is updated. return isset( $button['singular'], $button['plural'] ) ? (string) $button[ 1 === (int) $count ? 'singular' : 'plural' ] : '%s'; } /** * New enquiries waiting under "Needs your attention". * * @param array[] $items Items. * @return array[] */ public static function attention( $items ) { if ( ! Ai::enabled() ) { return $items; } $ready = count( self::ready() ); $waiting = count( self::waiting() ); if ( $ready ) { $items[] = array( 'tone' => 'info', /* translators: %s: number of drafts */ 'title' => sprintf( _n( '%s draft is ready to send', '%s drafts are ready to send', $ready, 'dealroot' ), number_format_i18n( $ready ) ), 'text' => __( 'Read each answer, change what does not fit and send it with one click.', 'dealroot' ), 'url' => self::url(), 'action' => __( 'Go through them', 'dealroot' ), ); } elseif ( $waiting >= 2 ) { $items[] = array( 'tone' => 'info', /* translators: %s: number of enquiries */ 'title' => sprintf( _n( '%s new enquiry waits for an answer', '%s new enquiries wait for an answer', $waiting, 'dealroot' ), number_format_i18n( $waiting ) ), 'text' => __( 'The AI assistant writes a draft for each, and you go through them one after the other.', 'dealroot' ), 'url' => self::url(), 'action' => __( 'Go through them', 'dealroot' ), ); } return $items; } /** * The screen. */ public static function render() { $state = self::state(); $ready = self::ready(); $error = get_transient( 'dealroot_inbox_error_' . get_current_user_id() ); $sent = isset( $_GET['sent'] ) ? absint( $_GET['sent'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $busy = (bool) $state['queue']; $sets = self::sets(); $set = isset( $_GET['set'] ) ? sanitize_key( wp_unslash( $_GET['set'] ) ) : 'new'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $set = isset( $sets[ $set ] ) ? $set : 'new'; if ( $error ) { delete_transient( 'dealroot_inbox_error_' . get_current_user_id() ); } Admin\Shell::open( 'leads', $sets[ $set ]['title'], array( 'description' => $sets[ $set ]['description'], 'actions' => implode( '', array_map( static function ( $other ) { return self::button( '', $other ); }, array_diff( array_keys( $sets ), array( $set ) ) ) ), 'back' => array( 'url' => admin_url( 'admin.php?page=' . Admin\Admin::LEADS_PAGE ), 'label' => __( 'All leads', 'dealroot' ), ), ) ); ?>
>

$lead ) : ?>
name ) ? $lead->name : $lead->email; $id = 'dealroot-inbox-' . (int) $lead->id; ?>

created_at . ' UTC' ) ) ) ); ?>
ai_summary ) : ?>

ai_summary ); ?>

id ); ?>