'info', /* translators: %s: name of the person */ 'title' => sprintf( __( '%s wrote again', 'dealroot' ), '' !== trim( (string) $lead->name ) ? $lead->name : $lead->email ), 'text' => __( 'The new message is in the conversation of the enquiry.', 'dealroot' ), 'url' => admin_url( 'admin.php?page=dealroot&lead=' . (int) $lead->id ), 'action' => __( 'Read it', 'dealroot' ), ); } elseif ( $unread ) { $items[] = array( 'tone' => 'info', 'title' => __( 'People wrote again', 'dealroot' ), 'text' => implode( ', ', array_map( static fn( $lead ) => '' !== trim( (string) $lead->name ) ? $lead->name : $lead->email, $unread ) ), 'url' => admin_url( 'admin.php?page=dealroot-leads' ), 'action' => __( 'Open the leads', 'dealroot' ), ); } return $items; } /** * Table name. * * @return string */ public static function table() { global $wpdb; return $wpdb->prefix . 'dealroot_messages'; } /** * Stores a message. * * @param int $lead_id Lead ID. * @param string $direction "out" from the team, "in" from the person. * @param string $channel One of CHANNELS. * @param string $body Text. * @param array $extra subject, files (list of id, name, url, size), user_id. * @return int Message ID. */ public static function add( $lead_id, $direction, $channel, $body, array $extra = array() ) { global $wpdb; if ( ! in_array( $channel, self::CHANNELS, true ) || ! Leads::get( (int) $lead_id ) ) { return 0; } $now = current_time( 'mysql', true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( self::table(), array( 'lead_id' => (int) $lead_id, 'created_at' => $now, 'direction' => 'in' === $direction ? 'in' : 'out', 'channel' => $channel, 'user_id' => (int) ( $extra['user_id'] ?? ( 'in' === $direction ? 0 : get_current_user_id() ) ), 'subject' => mb_substr( sanitize_text_field( (string) ( $extra['subject'] ?? '' ) ), 0, 191 ), 'body' => mb_substr( sanitize_textarea_field( (string) $body ), 0, 20000 ), 'files' => ! empty( $extra['files'] ) ? wp_json_encode( array_values( (array) $extra['files'] ) ) : '', ) ); $id = (int) $wpdb->insert_id; if ( $id ) { $columns = array( 'last_message_at' => $now ); if ( 'in' === $direction ) { $columns['unread'] = 1; } $wpdb->update( Installer::table(), $columns, array( 'id' => (int) $lead_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } return $id; } /** * Messages of a lead, oldest first. * * @param int $lead_id Lead ID. * @return object[] */ public static function for_lead( $lead_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE lead_id = %d ORDER BY id ASC LIMIT 200', self::table(), (int) $lead_id ) ); } /** * Removes the messages of deleted leads. * * @param int[] $ids Lead IDs. */ public static function delete_for( array $ids ) { global $wpdb; $ids = array_filter( array_map( 'absint', $ids ) ); if ( $ids ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE lead_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( self::table() ), $ids ) ) ); } } /** * Marks the new messages of a lead as seen. * * @param object $lead Lead. */ public static function seen( $lead ) { global $wpdb; if ( ! empty( $lead->unread ) ) { $wpdb->update( Installer::table(), array( 'unread' => 0 ), array( 'id' => (int) $lead->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } } /** * Leads with a message the team has not seen yet, newest first. * * @param int $limit Most leads. * @return object[] */ public static function unread( $limit = 10 ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE unread = 1 AND status <> 'spam' ORDER BY last_message_at DESC LIMIT %d", Installer::table(), (int) $limit ) ); } /** * The open lead of the same person, for what they send again through a form: a new enquiry * is only made once the last one was won or lost, or is months old. * * @param array $data Columns of the new submission. * @return object|null Lead. */ public static function open_lead( array $data ) { global $wpdb; $email = (string) ( $data['email'] ?? '' ); $phone = self::digits( (string) ( $data['phone'] ?? '' ) ); if ( ( '' === $email && strlen( $phone ) < 6 ) || 'spam' === ( $data['status'] ?? '' ) || in_array( (string) ( $data['form_plugin'] ?? '' ), array( 'order', 'woocommerce', 'manual' ), true ) || Orders::is_checkout( (string) ( $data['form_name'] ?? '' ) ) ) { return null; } /** * Filters whether what a person sends again goes into their open lead. * * @param bool $join Whether to add it to the open lead. * @param array $data Columns of the new submission. */ if ( ! apply_filters( 'dealroot_join_open_lead', true, $data ) ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE status IN ('new', 'contacted', 'qualified') AND form_plugin NOT IN ('order', 'woocommerce', 'manual') AND created_at >= %s AND ( email = NULLIF(%s, '') OR phone <> '' ) ORDER BY id DESC LIMIT 50", Installer::table(), gmdate( 'Y-m-d H:i:s', time() - self::OPEN_DAYS * DAY_IN_SECONDS ), $email ) ); foreach ( $rows as $row ) { $same_email = '' !== $email && 0 === strcasecmp( (string) $row->email, $email ); $same_phone = strlen( $phone ) >= 6 && self::digits( (string) $row->phone ) === $phone; if ( ( $same_email || $same_phone ) && ! Orders::is_checkout( (string) $row->form_name ) ) { return $row; } } return null; } /** * What a person sent again through a form, as a message of their open lead. * * @param object $lead Open lead. * @param array $data Columns of the submission. * @param array $fields Normalized fields. * @return int Message ID, 0 when it only repeated what is there. */ public static function received( $lead, array $data, array $fields ) { $body = self::text_of( $fields, $data ); // The same form sent twice, e.g. a double click. $last = self::for_lead( (int) $lead->id ); $last = $last ? end( $last ) : null; if ( '' === $body || ( $last && 'in' === $last->direction && $last->body === $body ) || self::text_of( (array) json_decode( (string) $lead->fields, true ), (array) $lead ) === $body ) { return 0; } $id = self::add( (int) $lead->id, 'in', 'form', $body, array( 'subject' => (string) ( $data['form_name'] ?? '' ), 'user_id' => 0, ) ); if ( $id ) { /** * Fires after a person wrote again through a form of the site. * * @param int $lead_id Lead ID. * @param int $message_id Message ID. */ do_action( 'dealroot_message_received', (int) $lead->id, $id ); } return $id; } /** * What a person wrote, without the contact details and consent boxes. * * @param array $fields Normalized fields. * @param array $data Columns with name, email and phone. * @return string */ public static function text_of( array $fields, array $data ) { $skip = array_filter( array( (string) ( $data['email'] ?? '' ), (string) ( $data['phone'] ?? '' ), (string) ( $data['name'] ?? '' ) ), 'strlen' ); $lines = array(); foreach ( $fields as $field ) { $value = trim( (string) ( $field['value'] ?? '' ) ); if ( '' === $value || in_array( $value, $skip, true ) || in_array( strtolower( $value ), array( 'on', 'yes', 'true', '1', 'ja' ), true ) || in_array( (string) ( $field['type'] ?? '' ), array( 'email', 'tel', 'hidden', 'checkbox', 'consent' ), true ) ) { continue; } $lines[] = 'textarea' === ( $field['type'] ?? '' ) ? $value : trim( (string) ( $field['label'] ?? '' ) . ': ' . $value, ': ' ); } return trim( implode( "\n", $lines ) ); } /** * Files of the media library for an email, checked. * * @param int[] $ids Attachment IDs. * @return array|\WP_Error List of id, name, url, size and path. */ public static function files( array $ids ) { $files = array(); $bytes = 0; foreach ( array_slice( array_unique( array_filter( array_map( 'absint', $ids ) ) ), 0, self::MAX_FILES + 1 ) as $id ) { $path = 'attachment' === get_post_type( $id ) ? get_attached_file( $id ) : ''; if ( ! $path || ! is_readable( $path ) ) { return new \WP_Error( 'dealroot_file', __( 'A file could not be found. Choose it again.', 'dealroot' ) ); } $size = (int) filesize( $path ); $bytes += $size; $files[] = array( 'id' => $id, 'name' => wp_basename( $path ), 'url' => (string) wp_get_attachment_url( $id ), 'size' => $size, 'path' => $path, ); } if ( count( $files ) > self::MAX_FILES ) { /* translators: %s: number of files */ return new \WP_Error( 'dealroot_files', sprintf( __( 'Up to %s files can go with one email.', 'dealroot' ), number_format_i18n( self::MAX_FILES ) ) ); } if ( $bytes > self::MAX_BYTES ) { /* translators: %s: size, e.g. 15 MB */ return new \WP_Error( 'dealroot_files', sprintf( __( 'The files are too large for one email: up to %s together.', 'dealroot' ), size_format( self::MAX_BYTES ) ) ); } return $files; } /** * Sends the answer of the lead screen by email, with files. */ public static function ajax_send() { check_ajax_referer( 'dealroot_quick_status', 'nonce' ); if ( ! current_user_can( Admin::capability() ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to edit leads.', 'dealroot' ) ), 403 ); } $lead = Leads::get( isset( $_POST['lead'] ) ? absint( $_POST['lead'] ) : 0 ); $subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : ''; $text = isset( $_POST['text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['text'] ) ) : ''; $ids = isset( $_POST['files'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['files'] ) ) : array(); $via = isset( $_POST['via'] ) && 'ai' === $_POST['via'] ? 'ai' : 'reply'; if ( ! $lead || ! is_email( $lead->email ) ) { wp_send_json_error( array( 'message' => __( 'This lead has no email address.', 'dealroot' ) ) ); } if ( '' === trim( $text ) ) { wp_send_json_error( array( 'message' => __( 'Write the message first.', 'dealroot' ) ) ); } $files = self::files( $ids ); if ( is_wp_error( $files ) ) { wp_send_json_error( array( 'message' => $files->get_error_message() ) ); } $subject = '' !== $subject ? $subject : Ai::in_locale( Ai::customer_locale( $lead ), fn() => __( 'Your enquiry', 'dealroot' ) ); $sent = Mail::customer( $lead->email, $subject, $text, array( 'attachments' => wp_list_pluck( $files, 'path' ) ) ); if ( ! $sent ) { wp_send_json_error( array( 'message' => __( 'The email could not be sent. Check the email settings of your site.', 'dealroot' ) ) ); } $kept = array_map( static function ( $file ) { unset( $file['path'] ); return $file; }, $files ); $id = self::add( (int) $lead->id, 'out', 'email', $text, array( 'subject' => $subject, 'files' => $kept, ) ); self::answered( $lead, $via ); /** This action is documented in includes/class-inbox.php */ do_action( 'dealroot_answer_sent', (int) $lead->id, $text ); wp_send_json_success( array( /* translators: %s: email address */ 'message' => sprintf( __( 'Sent to %s.', 'dealroot' ), $lead->email ), 'item' => self::item_html( self::get( $id ) ), 'status' => Leads::get( (int) $lead->id )->status, ) ); } /** * Notes an answer written in the email program or on WhatsApp, which Dealroot does not send. */ public static function ajax_logged() { check_ajax_referer( 'dealroot_quick_status', 'nonce' ); if ( ! current_user_can( Admin::capability() ) ) { wp_send_json_error( null, 403 ); } $lead = Leads::get( isset( $_POST['lead'] ) ? absint( $_POST['lead'] ) : 0 ); $channel = isset( $_POST['channel'] ) && 'whatsapp' === $_POST['channel'] ? 'whatsapp' : 'mail_app'; $subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : ''; $text = isset( $_POST['text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['text'] ) ) : ''; $via = isset( $_POST['via'] ) && 'ai' === $_POST['via'] ? 'ai' : 'reply'; if ( ! $lead || '' === trim( $text ) ) { wp_send_json_error(); } $id = self::add( (int) $lead->id, 'out', $channel, $text, array( 'subject' => 'mail_app' === $channel ? $subject : '' ) ); self::answered( $lead, $via ); /** This action is documented in includes/class-inbox.php */ do_action( 'dealroot_answer_sent', (int) $lead->id, $text ); wp_send_json_success( array( 'item' => self::item_html( self::get( $id ) ), 'status' => Leads::get( (int) $lead->id )->status, ) ); } /** * Takes an answer noted by hand out of the conversation, when it was not sent after all. * Only answers written in the email program or on WhatsApp; what Dealroot sent stays. */ public static function ajax_unlog() { global $wpdb; check_ajax_referer( 'dealroot_quick_status', 'nonce' ); if ( ! current_user_can( Admin::capability() ) ) { wp_send_json_error( null, 403 ); } $message = self::get( isset( $_POST['message'] ) ? absint( $_POST['message'] ) : 0 ); if ( ! $message || ! self::noted_by_hand( $message ) ) { wp_send_json_error(); } $wpdb->delete( self::table(), array( 'id' => (int) $message->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching wp_send_json_success(); } /** * Whether an answer was written outside Dealroot and only noted here. * * @param object $message Message. * @return bool */ private static function noted_by_hand( $message ) { return 'out' === $message->direction && in_array( $message->channel, array( 'mail_app', 'whatsapp' ), true ); } /** * A new lead that was answered is contacted. * * @param object $lead Lead. * @param string $via "ai" when the text came from the assistant, otherwise "reply". */ private static function answered( $lead, $via ) { if ( 'new' === $lead->status ) { Impact::answered( (int) $lead->id, $via ); Leads::update( (int) $lead->id, array( 'status' => 'contacted' ) ); } else { Leads::update( (int) $lead->id, array() ); } } /** * The last digits of a phone number, the same with or without the country code. * * @param string $phone Phone number. * @return string */ private static function digits( $phone ) { return substr( (string) preg_replace( '/\D/', '', $phone ), -9 ); } /** * Date and time in the formats of the site. * * @param string $gmt Time in UTC. * @return string */ private static function when( $gmt ) { return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $gmt . ' UTC' ) ); } /** * One message. * * @param int $id Message ID. * @return object|null */ public static function get( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', self::table(), (int) $id ) ); } /** * How a message was sent or came in. * * @param object $message Message. * @return string */ private static function how( $message ) { $labels = array( 'email' => __( 'Email from Dealroot', 'dealroot' ), 'mail_app' => __( 'Written in your email program', 'dealroot' ), 'whatsapp' => __( 'WhatsApp', 'dealroot' ), 'form' => __( 'Form of your site', 'dealroot' ), 'telegram' => __( 'Email, sent from Telegram', 'dealroot' ), 'confirm' => __( 'Automatic confirmation', 'dealroot' ), 'offer' => __( 'Offer by email', 'dealroot' ), ); return $labels[ $message->channel ] ?? ''; } /** * One message in the conversation. * * @param object|null $message Message. * @return string HTML. */ public static function item_html( $message ) { if ( ! $message ) { return ''; } $user = $message->user_id ? get_userdata( (int) $message->user_id ) : null; $who = 'in' === $message->direction ? __( 'They wrote', 'dealroot' ) : ( $user ? $user->display_name : __( 'Dealroot', 'dealroot' ) ); $files = (array) json_decode( (string) $message->files, true ); ob_start(); ?>
  • subject && 'form' !== $message->channel ) : ?>

    subject ); ?>

    body ) ); ?>
  • id ); ?>

    1. form_name ? $lead->form_name : __( 'Form', 'dealroot' ) ) ); ?>
      fields, true ), (array) $lead ); ?>