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 ) ? '
' . '
Your message' . ( $date ? ' (' . esc_html( $date ) . ')' : '' ) . ':
' . nl2br( esc_html( $original ) ) . '
' : ''; return '' . '
' . '' . '' . '' . '' . '
' . esc_html( $site ) . '
' . self::text_html( $body ) . $quote . '
' . esc_html( sprintf( 'You are receiving this because you contacted %s through the chat on our website.', $site ) ) . ' ' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '
'; } /* ------------------------------------------------------------------ */ /* Escalation, tidying and email alerts */ /* ------------------------------------------------------------------ */ private static function escalate_if_due( array $chat ) { $minutes = (int) Replyvane_Plugin::get( 'escalate_minutes' ); if ( $minutes > 0 && strtotime( $chat['created_at'] . ' UTC' ) <= time() - $minutes * MINUTE_IN_SECONDS ) { self::escalate( $chat['id'] ); } } /** Emails the team once if a chat is still waiting. Safe to call repeatedly. */ public static function escalate( $chat_id ) { if ( ! self::enabled() ) { return; } global $wpdb; $table = self::chats_table(); $changed = $wpdb->query( $wpdb->prepare( "UPDATE %i SET escalated = 1 WHERE id = %d AND status = 'waiting' AND escalated = 0", $table, $chat_id ) ); if ( $changed ) { self::email_team( 'escalate', self::get_chat( $chat_id ) ); } } /** Runs on every dashboard check and daily: escalates, and tidies chats whose visitor has gone. */ public static function housekeeping() { global $wpdb; $table = self::chats_table(); $minutes = (int) Replyvane_Plugin::get( 'escalate_minutes' ); if ( $minutes > 0 ) { $due = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE status = 'waiting' AND escalated = 0 AND created_at <= %s LIMIT 20", $table, gmdate( 'Y-m-d H:i:s', time() - $minutes * MINUTE_IN_SECONDS ) ) ); foreach ( (array) $due as $id ) { self::escalate( $id ); } } // Visitor left before anyone joined: keep it as a message to answer by email. $left = $wpdb->get_results( $wpdb->prepare( "SELECT id, escalated FROM %i WHERE status = 'waiting' AND last_visitor_at < %s LIMIT 20", $table, gmdate( 'Y-m-d H:i:s', time() - self::WAITING_LEFT ) ), ARRAY_A ); foreach ( (array) $left as $row ) { $changed = $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'offline' WHERE id = %d AND status = 'waiting'", $table, $row['id'] ) ); if ( $changed && ! $row['escalated'] ) { self::email_team( 'left', self::get_chat( $row['id'] ) ); } } // Visitor gone for an hour from an active chat: close it. $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'closed', closed_at = %s WHERE status = 'active' AND last_visitor_at < %s", $table, self::now(), gmdate( 'Y-m-d H:i:s', time() - self::ACTIVE_IDLE ) ) ); } private static function team_addresses() { $out = array(); foreach ( explode( ',', (string) Replyvane_Plugin::get( 'team_email' ) ) as $address ) { $address = sanitize_email( trim( $address ) ); if ( is_email( $address ) ) { $out[] = $address; } } return $out; } /** * @param string $type "new", "offline" or "escalate". */ private static function email_team( $type, $chat, $message = '' ) { $to = self::team_addresses(); if ( ! $chat || empty( $to ) ) { return; } $name = $chat['visitor_name']; $link = admin_url( 'admin.php?page=replyvane-live&chat=' . (int) $chat['id'] ); $include = (bool) Replyvane_Plugin::get( 'alert_include_text' ); $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); switch ( $type ) { case 'offline': $subject = "New message from {$name} (live chat was offline)"; $intro = "{$name} left a message while nobody was online. Please reply by email."; break; case 'left': $subject = "{$name} left before anyone joined the chat"; $intro = "{$name} started a chat but left before anyone joined. Please reply by email."; break; case 'escalate': $subject = "Chat from {$name} is still waiting"; $intro = sprintf( '%s has been waiting %d minutes and nobody has taken the chat yet.', $name, (int) Replyvane_Plugin::get( 'escalate_minutes' ) ); break; default: $subject = "New live chat from {$name}"; $intro = "{$name} started a live chat on the website."; } $lines = array( $intro, '' ); $lines[] = 'Name: ' . $name; if ( $chat['visitor_email'] ) { $lines[] = 'Email: ' . $chat['visitor_email']; } if ( $chat['page_url'] ) { $lines[] = 'Page: ' . $chat['page_url']; } if ( $include && '' !== $message ) { $lines[] = ''; $lines[] = 'Message:'; $lines[] = $message; } else { $lines[] = ''; $lines[] = 'Open the Live Chat screen to read the message.'; } $lines[] = ''; $lines[] = 'Open this chat: ' . $link; $headers = array(); if ( in_array( $type, array( 'offline', 'left', 'escalate' ), true ) && is_email( $chat['visitor_email'] ) ) { $headers[] = 'Reply-To: ' . $name . ' <' . $chat['visitor_email'] . '>'; } wp_mail( $to, '[' . $site . '] ' . $subject, implode( "\n", $lines ), $headers ); } /** Deletes chats older than the retention period (daily). */ public static function cleanup() { global $wpdb; self::housekeeping(); $days = (int) Replyvane_Plugin::get( 'live_days' ); if ( $days <= 0 ) { return; } $chats = self::chats_table(); $messages = self::messages_table(); $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $wpdb->query( $wpdb->prepare( "DELETE m FROM %i m INNER JOIN %i c ON c.id = m.chat_id WHERE c.status = 'closed' AND c.created_at < %s", $messages, $chats, $cutoff ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status = 'closed' AND created_at < %s", $chats, $cutoff ) ); } public static function delete_chat( $chat_id ) { global $wpdb; $wpdb->delete( self::messages_table(), array( 'chat_id' => (int) $chat_id ), array( '%d' ) ); $wpdb->delete( self::chats_table(), array( 'id' => (int) $chat_id ), array( '%d' ) ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching