prefix . 'replyvane_live_chats'; } public static function messages_table() { global $wpdb; return $wpdb->prefix . 'replyvane_live_messages'; } public static function create_tables() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $chats = self::chats_table(); $messages = self::messages_table(); dbDelta( "CREATE TABLE {$chats} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, token_hash char(64) NOT NULL, status varchar(10) NOT NULL DEFAULT 'waiting', visitor_name varchar(80) NOT NULL DEFAULT '', visitor_email varchar(190) NOT NULL DEFAULT '', page_url varchar(500) NOT NULL DEFAULT '', trail text NOT NULL, agent_id bigint(20) unsigned NOT NULL DEFAULT 0, agent_read_id bigint(20) unsigned NOT NULL DEFAULT 0, escalated tinyint(1) NOT NULL DEFAULT 0, created_at datetime NOT NULL, claimed_at datetime NULL DEFAULT NULL, closed_at datetime NULL DEFAULT NULL, last_visitor_at datetime NULL DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash), KEY status (status), KEY agent_id (agent_id), KEY created_at (created_at) ) {$charset};" ); dbDelta( "CREATE TABLE {$messages} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, chat_id bigint(20) unsigned NOT NULL, sender varchar(10) NOT NULL DEFAULT 'visitor', agent_id bigint(20) unsigned NOT NULL DEFAULT 0, body text NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), KEY chat_id (chat_id,id) ) {$charset};" ); } /** * Administrators can always answer chats. A "Live Chat Agent" role lets * other staff answer chats without being able to change the site. */ public static function setup_roles() { $admin = get_role( 'administrator' ); if ( $admin && ! $admin->has_cap( self::CAP ) ) { $admin->add_cap( self::CAP ); } if ( ! get_role( self::ROLE ) ) { add_role( self::ROLE, 'Live Chat Agent', array( 'read' => true, 'view_admin_dashboard' => true, // Lets WooCommerce allow this role into the dashboard. self::CAP => true, ) ); } } private static function now() { return current_time( 'mysql', true ); } private static function ms( $mysql_utc ) { return $mysql_utc ? strtotime( $mysql_utc . ' UTC' ) * 1000 : null; } private static function no_store( $data, $status = 200 ) { $response = new WP_REST_Response( $data, $status ); $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); return $response; } private static function fail( $message, $status, $code = 'error' ) { return self::no_store( array( 'code' => $code, 'message' => $message, ), $status ); } /* ------------------------------------------------------------------ */ /* Availability */ /* ------------------------------------------------------------------ */ public static function within_hours() { if ( ! Replyvane_Plugin::get( 'hours_enabled' ) ) { return true; } $now = current_datetime(); // Site timezone. $days = array_map( 'intval', (array) Replyvane_Plugin::get( 'hours_days' ) ); if ( ! in_array( (int) $now->format( 'N' ), $days, true ) ) { return false; } $time = $now->format( 'H:i' ); return $time >= (string) Replyvane_Plugin::get( 'hours_start' ) && $time < (string) Replyvane_Plugin::get( 'hours_end' ); } /** Team members who chose Online and whose dashboard checked in recently. */ public static function online_agents() { $users = get_users( array( 'fields' => 'ID', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery 'relation' => 'AND', array( 'key' => 'replyvane_live_status', 'value' => 'online', ), array( 'key' => 'replyvane_live_seen', 'value' => time() - self::ONLINE_WINDOW, 'compare' => '>=', 'type' => 'NUMERIC', ), ), ) ); return array_values( array_filter( array_map( 'intval', $users ), function ( $id ) { return user_can( $id, self::CAP ); } ) ); } public static function available() { return self::enabled() && self::within_hours() && count( self::online_agents() ) > 0; } public static function agent_status( $user_id ) { // The member's own choice. Whether they count as available to visitors // also depends on a recent dashboard check-in (see online_agents()). return 'online' === get_user_meta( $user_id, 'replyvane_live_status', true ) ? 'online' : 'away'; } /** * The team member's profile picture (Gravatar or a profile-picture plugin), * or '' when photos are switched off. If they have no picture, the chat * window shows a person icon instead. */ public static function agent_avatar( $user_id ) { static $cache = array(); $user_id = (int) $user_id; if ( ! $user_id || ! Replyvane_Plugin::get( 'agent_avatars' ) ) { return ''; } if ( ! isset( $cache[ $user_id ] ) ) { $url = get_avatar_url( $user_id, array( 'size' => 96, 'default' => '404' ) ); $cache[ $user_id ] = $url ? esc_url_raw( $url ) : ''; } return $cache[ $user_id ]; } public static function agent_name( $user_id ) { static $cache = array(); $user_id = (int) $user_id; if ( ! $user_id ) { return ''; } if ( ! isset( $cache[ $user_id ] ) ) { $first = get_user_meta( $user_id, 'first_name', true ); $user = get_userdata( $user_id ); $cache[ $user_id ] = $first ? $first : ( $user ? $user->display_name : 'Our team' ); } return $cache[ $user_id ]; } /* ------------------------------------------------------------------ */ /* Routes */ /* ------------------------------------------------------------------ */ public static function routes() { $ns = 'replyvane/v1'; $public = '__return_true'; $agent = array( __CLASS__, 'can_agent' ); $visitor = array( '/live/status' => array( 'GET', 'visitor_status' ), '/live/start' => array( 'POST', 'visitor_start' ), '/live/poll' => array( 'GET', 'visitor_poll' ), '/live/send' => array( 'POST', 'visitor_send' ), '/live/typing' => array( 'POST', 'visitor_typing' ), '/live/end' => array( 'POST', 'visitor_end' ), ); foreach ( $visitor as $path => $def ) { register_rest_route( $ns, $path, array( 'methods' => $def[0], 'callback' => array( __CLASS__, $def[1] ), 'permission_callback' => $public, ) ); } $team = array( '/live/agent/sync' => array( 'GET', 'agent_sync' ), '/live/agent/status' => array( 'POST', 'agent_set_status' ), '/live/agent/claim' => array( 'POST', 'agent_claim' ), '/live/agent/send' => array( 'POST', 'agent_send' ), '/live/agent/close' => array( 'POST', 'agent_close' ), '/live/agent/email' => array( 'POST', 'agent_email' ), '/live/agent/typing' => array( 'POST', 'agent_typing' ), ); foreach ( $team as $path => $def ) { register_rest_route( $ns, $path, array( 'methods' => $def[0], 'callback' => array( __CLASS__, $def[1] ), 'permission_callback' => $agent, ) ); } } public static function can_agent() { return self::enabled() && current_user_can( self::CAP ); } /* ------------------------------------------------------------------ */ /* Shared helpers */ /* ------------------------------------------------------------------ */ private static function get_chat( $id ) { global $wpdb; $table = self::chats_table(); return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE id = %d", $table, $id ), ARRAY_A ); } private static function chat_from_token( WP_REST_Request $request ) { global $wpdb; $token = (string) $request->get_header( 'x-replyvane-token' ); if ( ! preg_match( '/^[a-f0-9]{48}$/', $token ) ) { return null; } $table = self::chats_table(); return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE token_hash = %s", $table, hash( 'sha256', $token ) ), ARRAY_A ); } private static function add_message( $chat_id, $sender, $body, $agent_id = 0 ) { global $wpdb; $wpdb->insert( self::messages_table(), array( 'chat_id' => (int) $chat_id, 'sender' => $sender, 'agent_id' => (int) $agent_id, 'body' => $body, 'created_at' => self::now(), ), array( '%d', '%s', '%d', '%s', '%s' ) ); return (int) $wpdb->insert_id; } private static function messages_after( $chat_id, $after, $for_visitor = false ) { global $wpdb; $table = self::messages_table(); // Visitors never receive the team's email copies through the chat window. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE chat_id = %d AND id > %d AND ( %d = 0 OR sender <> 'email' ) ORDER BY id ASC LIMIT 300", $table, $chat_id, $after, $for_visitor ? 1 : 0 ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $m ) { $out[] = array( 'id' => (int) $m['id'], 'sender' => $m['sender'], 'name' => in_array( $m['sender'], array( 'agent', 'email' ), true ) ? self::agent_name( $m['agent_id'] ) : '', 'avatar' => $for_visitor && 'agent' === $m['sender'] ? self::agent_avatar( $m['agent_id'] ) : '', 'body' => $m['body'], 'time' => self::ms( $m['created_at'] ), ); } return $out; } private static function clean_message( $text ) { $text = sanitize_textarea_field( (string) $text ); return trim( mb_substr( $text, 0, 2000 ) ); } private static function same_site_url( $url ) { $url = esc_url_raw( (string) $url ); $home = preg_replace( '/^www\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); return $url && preg_replace( '/^www\./', '', (string) wp_parse_url( $url, PHP_URL_HOST ) ) === $home ? mb_substr( $url, 0, 500 ) : ''; } /** Simple per-IP limits so bots can't flood the inbox. */ private static function within_limit( $action, $max, $seconds ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'replyvane_live_' . $action . '_' . md5( $ip . wp_salt( 'nonce' ) ); $count = (int) get_transient( $key ); if ( $count >= $max ) { return false; } set_transient( $key, $count + 1, $seconds ); return true; } /* ------------------------------------------------------------------ */ /* Visitor endpoints */ /* ------------------------------------------------------------------ */ public static function visitor_status() { wp_set_current_user( 0 ); return self::no_store( array( 'enabled' => self::enabled(), 'available' => self::available(), ) ); } public static function visitor_start( WP_REST_Request $request ) { global $wpdb; wp_set_current_user( 0 ); if ( ! self::enabled() ) { return self::fail( 'Live chat is not available.', 404, 'disabled' ); } // Hidden field that people never fill in but bots often do. if ( '' !== trim( (string) $request->get_param( 'website' ) ) ) { return self::no_store( array( 'status' => 'offline' ) ); } if ( ! self::within_limit( 'start', 5, HOUR_IN_SECONDS ) ) { return self::fail( "You've started several chats recently. Please wait a little and try again, or use the contact page.", 429 ); } $name = mb_substr( trim( sanitize_text_field( (string) $request->get_param( 'name' ) ) ), 0, 80 ); $email = sanitize_email( (string) $request->get_param( 'email' ) ); $message = self::clean_message( $request->get_param( 'message' ) ); if ( '' === $name ) { return self::fail( 'Please enter your name.', 400, 'name' ); } if ( ( Replyvane_Plugin::get( 'require_email' ) || '' !== (string) $request->get_param( 'email' ) ) && ! is_email( $email ) ) { return self::fail( 'Please enter a valid email address.', 400, 'email' ); } if ( '' === $message ) { return self::fail( 'Please type your question.', 400, 'message' ); } $trail = array(); foreach ( array_slice( (array) $request->get_param( 'trail' ), 0, 10 ) as $q ) { $q = mb_substr( trim( sanitize_text_field( (string) $q ) ), 0, 200 ); if ( '' !== $q ) { $trail[] = $q; } } $available = self::available(); $token = bin2hex( random_bytes( 24 ) ); $now = self::now(); $wpdb->insert( self::chats_table(), array( 'token_hash' => hash( 'sha256', $token ), 'status' => $available ? 'waiting' : 'offline', 'visitor_name' => $name, 'visitor_email' => $email, 'page_url' => self::same_site_url( $request->get_param( 'page' ) ), 'trail' => wp_json_encode( $trail ), 'created_at' => $now, 'last_visitor_at' => $now, ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); $chat_id = (int) $wpdb->insert_id; if ( ! $chat_id ) { return self::fail( "Your message couldn't be saved. Please try again, or use the contact page.", 500 ); } self::add_message( $chat_id, 'visitor', $message ); $chat = self::get_chat( $chat_id ); if ( $available ) { $minutes = (int) Replyvane_Plugin::get( 'escalate_minutes' ); if ( $minutes > 0 ) { wp_schedule_single_event( time() + $minutes * MINUTE_IN_SECONDS, 'replyvane_live_escalate', array( $chat_id ) ); } if ( Replyvane_Plugin::get( 'email_new_chat' ) ) { self::email_team( 'new', $chat, $message ); } return self::no_store( array( 'status' => 'waiting', 'token' => $token, ) ); } self::email_team( 'offline', $chat, $message ); return self::no_store( array( 'status' => 'offline' ) ); } public static function visitor_poll( WP_REST_Request $request ) { global $wpdb; wp_set_current_user( 0 ); $chat = self::chat_from_token( $request ); if ( ! $chat ) { return self::fail( 'This chat is no longer available.', 404, 'no_chat' ); } // Record that the visitor is still here (at most every 10 seconds). if ( ! $chat['last_visitor_at'] || strtotime( $chat['last_visitor_at'] . ' UTC' ) < time() - 10 ) { $wpdb->update( self::chats_table(), array( 'last_visitor_at' => self::now() ), array( 'id' => $chat['id'] ) ); } if ( 'waiting' === $chat['status'] && ! $chat['escalated'] ) { self::escalate_if_due( $chat ); $chat = self::get_chat( $chat['id'] ); } return self::no_store( array( 'status' => $chat['status'], 'escalated' => (bool) $chat['escalated'], 'agent' => self::agent_name( $chat['agent_id'] ), 'avatar' => self::agent_avatar( $chat['agent_id'] ), 'typing' => 'active' === $chat['status'] && (bool) get_transient( 'replyvane_live_at_' . $chat['id'] ), 'messages' => self::messages_after( $chat['id'], absint( $request->get_param( 'after' ) ), true ), ) ); } public static function visitor_send( WP_REST_Request $request ) { global $wpdb; wp_set_current_user( 0 ); $chat = self::chat_from_token( $request ); if ( ! $chat ) { return self::fail( 'This chat is no longer available.', 404, 'no_chat' ); } if ( ! in_array( $chat['status'], array( 'waiting', 'active' ), true ) ) { return self::fail( 'This chat has ended.', 409, 'closed' ); } if ( ! self::within_limit( 'send', 60, 10 * MINUTE_IN_SECONDS ) ) { return self::fail( "You're sending messages very quickly. Please wait a moment.", 429 ); } $message = self::clean_message( $request->get_param( 'message' ) ); if ( '' === $message ) { return self::fail( 'Please type a message.', 400 ); } $id = self::add_message( $chat['id'], 'visitor', $message ); delete_transient( 'replyvane_live_vt_' . $chat['id'] ); $wpdb->update( self::chats_table(), array( 'last_visitor_at' => self::now() ), array( 'id' => $chat['id'] ) ); return self::no_store( array( 'id' => $id ) ); } /** * "Is typing" signals. Each lasts 6 seconds; the typing side sends it again * every few seconds while typing, and sending the message clears it. */ public static function visitor_typing( WP_REST_Request $request ) { wp_set_current_user( 0 ); $chat = self::chat_from_token( $request ); if ( $chat && in_array( $chat['status'], array( 'waiting', 'active' ), true ) && self::within_limit( 'typing', 120, 10 * MINUTE_IN_SECONDS ) ) { set_transient( 'replyvane_live_vt_' . $chat['id'], 1, 6 ); } return self::no_store( array( 'ok' => true ) ); } public static function agent_typing( WP_REST_Request $request ) { $chat = self::get_chat( absint( $request->get_param( 'chat' ) ) ); $uid = get_current_user_id(); if ( $chat && 'active' === $chat['status'] && ( (int) $chat['agent_id'] === $uid || current_user_can( 'manage_options' ) ) ) { set_transient( 'replyvane_live_at_' . $chat['id'], $uid, 6 ); } return self::no_store( array( 'ok' => true ) ); } public static function visitor_end( WP_REST_Request $request ) { global $wpdb; wp_set_current_user( 0 ); $chat = self::chat_from_token( $request ); if ( $chat && in_array( $chat['status'], array( 'waiting', 'active' ), true ) ) { $wpdb->update( self::chats_table(), array( 'status' => 'closed', 'closed_at' => self::now(), ), array( 'id' => $chat['id'] ) ); self::add_message( $chat['id'], 'system', 'The visitor ended the chat.' ); } return self::no_store( array( 'ok' => true ) ); } /* ------------------------------------------------------------------ */ /* Team endpoints */ /* ------------------------------------------------------------------ */ /** * One call does everything the dashboard needs: records that this team * member is still here, returns the inbox, and (when a chat is open) * returns its new messages. */ public static function agent_sync( WP_REST_Request $request ) { global $wpdb; $uid = get_current_user_id(); update_user_meta( $uid, 'replyvane_live_seen', time() ); self::housekeeping(); $chats = self::chats_table(); $messages = self::messages_table(); // The chat open on screen first, so it no longer counts as unread below. $open_chat = null; $chat_id = absint( $request->get_param( 'chat' ) ); if ( $chat_id ) { $chat = self::get_chat( $chat_id ); if ( $chat ) { $new = self::messages_after( $chat_id, absint( $request->get_param( 'after' ) ) ); if ( $new && (int) $chat['agent_id'] === $uid ) { $last = end( $new ); $wpdb->update( $chats, array( 'agent_read_id' => $last['id'] ), array( 'id' => $chat_id ) ); } $open_chat = array( 'id' => (int) $chat['id'], 'status' => $chat['status'], 'name' => $chat['visitor_name'], 'email' => $chat['visitor_email'], 'page' => $chat['page_url'], 'trail' => Replyvane_Store::decode_list( $chat['trail'] ), 'agent' => self::agent_name( $chat['agent_id'] ), 'agent_id' => (int) $chat['agent_id'], 'mine' => (int) $chat['agent_id'] === $uid, 'created' => self::ms( $chat['created_at'] ), 'visitor_online' => $chat['last_visitor_at'] && strtotime( $chat['last_visitor_at'] . ' UTC' ) >= time() - 30, 'typing' => in_array( $chat['status'], array( 'waiting', 'active' ), true ) && (bool) get_transient( 'replyvane_live_vt_' . $chat['id'] ), 'messages' => $new, ); } } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT c.*, (SELECT COUNT(*) FROM %i m WHERE m.chat_id = c.id AND m.sender = 'visitor' AND m.id > c.agent_read_id) AS unread, (SELECT m2.body FROM %i m2 WHERE m2.chat_id = c.id AND m2.sender <> 'system' ORDER BY m2.id DESC LIMIT 1) AS last_body, (SELECT MAX(m3.id) FROM %i m3 WHERE m3.chat_id = c.id) AS last_id FROM %i c WHERE c.status IN ('waiting', 'active', 'offline') ORDER BY c.created_at ASC LIMIT 100", $messages, $messages, $messages, $chats ), ARRAY_A ); $lists = array( 'waiting' => array(), 'mine' => array(), 'others' => array(), 'offline' => array(), ); $unread_mine = 0; foreach ( (array) $rows as $r ) { $item = array( 'id' => (int) $r['id'], 'name' => $r['visitor_name'], 'status' => $r['status'], 'agent' => self::agent_name( $r['agent_id'] ), 'mine' => (int) $r['agent_id'] === $uid, 'unread' => (int) $r['unread'], 'snippet' => mb_substr( (string) $r['last_body'], 0, 90 ), 'last_id' => (int) $r['last_id'], 'created' => self::ms( $r['created_at'] ), 'visitor_online' => $r['last_visitor_at'] && strtotime( $r['last_visitor_at'] . ' UTC' ) >= time() - 30, 'escalated' => (bool) $r['escalated'], ); if ( 'waiting' === $r['status'] ) { $lists['waiting'][] = $item; } elseif ( 'offline' === $r['status'] ) { $lists['offline'][] = $item; } elseif ( $item['mine'] ) { $lists['mine'][] = $item; $unread_mine += $item['unread']; } else { $lists['others'][] = $item; } } $data = array( 'status' => self::agent_status( $uid ), 'available' => self::available(), 'online' => count( self::online_agents() ), 'lists' => $lists, 'counts' => array( 'waiting' => count( $lists['waiting'] ), 'unread' => $unread_mine, 'offline' => count( $lists['offline'] ), ), ); if ( $open_chat ) { $data['chat'] = $open_chat; } return self::no_store( $data ); } public static function agent_set_status( WP_REST_Request $request ) { $uid = get_current_user_id(); $status = 'online' === $request->get_param( 'status' ) ? 'online' : 'away'; update_user_meta( $uid, 'replyvane_live_status', $status ); update_user_meta( $uid, 'replyvane_live_seen', time() ); return self::no_store( array( 'status' => $status, 'available' => self::available(), ) ); } public static function agent_claim( WP_REST_Request $request ) { global $wpdb; $uid = get_current_user_id(); $chat_id = absint( $request->get_param( 'chat' ) ); $table = self::chats_table(); // Only one team member can win: the update only succeeds while nobody has the chat. $won = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'active', agent_id = %d, claimed_at = %s WHERE id = %d AND status = 'waiting' AND agent_id = 0", $table, $uid, self::now(), $chat_id ) ); if ( ! $won ) { $chat = self::get_chat( $chat_id ); $who = $chat ? self::agent_name( $chat['agent_id'] ) : ''; return self::fail( $who ? "{$who} is already handling this chat." : 'This chat is no longer waiting.', 409, 'taken' ); } self::add_message( $chat_id, 'system', self::agent_name( $uid ) . ' joined the chat.' ); return self::no_store( array( 'ok' => true ) ); } public static function agent_send( WP_REST_Request $request ) { global $wpdb; $uid = get_current_user_id(); $chat_id = absint( $request->get_param( 'chat' ) ); $chat = self::get_chat( $chat_id ); if ( ! $chat ) { return self::fail( 'Chat not found.', 404 ); } if ( 'offline' === $chat['status'] ) { return self::fail( 'This visitor left a message while nobody was online. Please reply by email.', 409, 'offline' ); } if ( 'closed' === $chat['status'] ) { return self::fail( 'This chat has ended.', 409, 'closed' ); } if ( 'waiting' === $chat['status'] ) { $claim = self::agent_claim( $request ); if ( 200 !== $claim->get_status() ) { return $claim; } } elseif ( (int) $chat['agent_id'] !== $uid && ! current_user_can( 'manage_options' ) ) { return self::fail( self::agent_name( $chat['agent_id'] ) . ' is handling this chat.', 403, 'not_yours' ); } $message = self::clean_message( $request->get_param( 'message' ) ); if ( '' === $message ) { return self::fail( 'Please type a message.', 400 ); } $id = self::add_message( $chat_id, 'agent', $message, $uid ); delete_transient( 'replyvane_live_at_' . $chat_id ); $wpdb->update( self::chats_table(), array( 'agent_read_id' => $id ), array( 'id' => $chat_id ) ); return self::no_store( array( 'id' => $id ) ); } public static function agent_close( WP_REST_Request $request ) { global $wpdb; $uid = get_current_user_id(); $chat_id = absint( $request->get_param( 'chat' ) ); $chat = self::get_chat( $chat_id ); if ( ! $chat || 'closed' === $chat['status'] ) { return self::no_store( array( 'ok' => true ) ); } if ( 'active' === $chat['status'] && (int) $chat['agent_id'] !== $uid && ! current_user_can( 'manage_options' ) ) { return self::fail( self::agent_name( $chat['agent_id'] ) . ' is handling this chat.', 403, 'not_yours' ); } $was = $chat['status']; $wpdb->update( self::chats_table(), array( 'status' => 'closed', 'closed_at' => self::now(), 'agent_id' => $chat['agent_id'] ? $chat['agent_id'] : $uid, ), array( 'id' => $chat_id ) ); if ( 'offline' !== $was ) { self::add_message( $chat_id, 'system', self::agent_name( $uid ) . ' ended the chat.' ); } return self::no_store( array( 'ok' => true ) ); } /* ------------------------------------------------------------------ */ /* Replying by email from the inbox */ /* ------------------------------------------------------------------ */ /** * Sends the team member's reply to the visitor by email, saves a copy in * the conversation, and marks a left message as handled. */ public static function agent_email( WP_REST_Request $request ) { global $wpdb; $uid = get_current_user_id(); $chat_id = absint( $request->get_param( 'chat' ) ); $chat = self::get_chat( $chat_id ); if ( ! $chat ) { return self::fail( 'Chat not found.', 404 ); } if ( ! is_email( $chat['visitor_email'] ) ) { return self::fail( "This visitor didn't leave an email address.", 400, 'no_email' ); } if ( 'active' === $chat['status'] && (int) $chat['agent_id'] !== $uid && ! current_user_can( 'manage_options' ) ) { return self::fail( self::agent_name( $chat['agent_id'] ) . ' is handling this chat.', 403, 'not_yours' ); } $subject = mb_substr( trim( sanitize_text_field( (string) $request->get_param( 'subject' ) ) ), 0, 200 ); $body = trim( mb_substr( sanitize_textarea_field( (string) $request->get_param( 'message' ) ), 0, 8000 ) ); if ( '' === $body ) { return self::fail( 'Please write your reply.', 400, 'empty' ); } $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); if ( '' === $subject ) { $subject = 'Re: your message to ' . $site; } // The visitor's first message, quoted under the reply for context. $table = self::messages_table(); $original = (string) $wpdb->get_var( $wpdb->prepare( "SELECT body FROM %i WHERE chat_id = %d AND sender = 'visitor' ORDER BY id ASC LIMIT 1", $table, $chat_id ) ); $agent = wp_get_current_user(); $agent_name = self::agent_name( $uid ); $headers = array( 'Content-Type: text/html; charset=UTF-8' ); if ( 'agent' === Replyvane_Plugin::get( 'reply_from' ) && is_email( $agent->user_email ) ) { $headers[] = 'From: ' . $agent_name . ' <' . $agent->user_email . '>'; } else { $from = is_email( Replyvane_Plugin::get( 'from_email' ) ) ? Replyvane_Plugin::get( 'from_email' ) : get_option( 'admin_email' ); $headers[] = 'From: ' . $site . ' <' . $from . '>'; if ( is_email( $agent->user_email ) ) { $headers[] = 'Reply-To: ' . $agent_name . ' <' . $agent->user_email . '>'; } } $html = self::email_html( $site, $chat['visitor_name'], $body, $original, $chat['created_at'] ); if ( ! wp_mail( $chat['visitor_email'], $subject, $html, $headers ) ) { return self::fail( "The email couldn't be sent. Check this website's email setup, for example with WP Mail SMTP, then try again.", 500, 'mail_failed' ); } $id = self::add_message( $chat_id, 'email', $body, $uid ); $update = array( 'agent_read_id' => $id ); if ( ! (int) $chat['agent_id'] ) { $update['agent_id'] = $uid; } if ( 'offline' === $chat['status'] ) { $update['status'] = 'closed'; $update['closed_at'] = self::now(); } $wpdb->update( self::chats_table(), $update, array( 'id' => $chat_id ) ); return self::no_store( array( 'ok' => true, 'id' => $id, ) ); } /** Turns plain text into safe HTML paragraphs, with web addresses as links. */ private static function text_html( $text ) { $out = array(); foreach ( preg_split( "/\n\s*\n/", trim( (string) $text ) ) as $para ) { $safe = esc_html( $para ); $safe = preg_replace( '#(https?://[^\s<]+[^\s<.,;:!?)\]])#', '$1', $safe ); $out[] = '
' . nl2br( $safe ) . '
'; } return implode( '', $out ); } /** A simple, readable email layout that works in all email apps. */ private static function email_html( $site, $name, $body, $original, $created ) { $accent = sanitize_hex_color( Replyvane_Plugin::get( 'color' ) ); $accent = $accent ? $accent : '#1f5c63'; $date = $created ? get_date_from_gmt( $created, get_option( 'date_format' ) ) : ''; $quote = '' !== trim( $original ) ? '| ' . ' |