'', 'rejected' => false, 'count' => 0, 'newest' => '', 'checked' => 0, ); $saved = get_option(RAYACHAT_INBOX_OPTION, array()); return array_merge($defaults, is_array($saved) ? $saved : array()); } // Autoloaded: the menu reads it on every admin page. function rayachat_inbox_update(array $changes) { update_option(RAYACHAT_INBOX_OPTION, array_merge(rayachat_inbox_settings(), $changes), true); } function rayachat_inbox_key_valid($token) { return is_string($token) && preg_match('/^rwp_[a-f0-9]{12}_[A-Za-z0-9_-]{40,}$/', $token) === 1; } function rayachat_inbox_active() { $inbox = rayachat_inbox_settings(); return $inbox['token'] !== '' && !$inbox['rejected']; } /** * Who sees the inbox and the dashboard widget. On a store, shop managers * answer the customers too; elsewhere, administrators. A site can change it * with the `rayachat_inbox_capability` filter. */ function rayachat_inbox_capability() { return (string) apply_filters('rayachat_inbox_capability', class_exists('WooCommerce') ? 'manage_woocommerce' : 'manage_options'); } function rayachat_inbox_can() { return current_user_can(rayachat_inbox_capability()); } // Teaching the bot changes what it tells every customer, so it asks for more // than answering does: in Rayachat an operator replies but can't correct. function rayachat_inbox_correction_capability() { return (string) apply_filters('rayachat_inbox_correction_capability', 'manage_options'); } function rayachat_inbox_url($conversation = '') { $url = admin_url('admin.php?page=rayachat-inbox'); return $conversation !== '' ? add_query_arg('conversation', rawurlencode($conversation), $url) : $url; } /** * Keeps the key «Connect to Rayachat» returned, or forgets the old one when * the owner didn't give this site the inbox this time. A key it replaces is * revoked on Rayachat, best-effort. */ function rayachat_inbox_adopt($token) { $old = rayachat_inbox_settings()['token']; if ($old !== '' && $old !== $token) { rayachat_inbox_request('POST', 'disconnect', array(), array(), $old); } delete_transient('rayachat_summary'); update_option(RAYACHAT_INBOX_OPTION, array( 'token' => rayachat_inbox_key_valid($token) ? $token : '', 'rejected' => false, 'count' => 0, 'newest' => '', 'checked' => 0, ), true); // The menu's count right away, rather than after the first heartbeat. if (rayachat_inbox_active()) { rayachat_inbox_refresh_badge(); } } /** Asks Rayachat how many customers wait for a person, and keeps the answer for the menu. */ function rayachat_inbox_refresh_badge() { rayachat_inbox_update(array('checked' => time())); $result = rayachat_inbox_request('GET', 'inbox/waiting'); if (!is_wp_error($result) && $result['code'] === 200 && is_array($result['body'])) { rayachat_inbox_update(array( 'count' => absint($result['body']['count'] ?? 0), 'newest' => sanitize_text_field((string) ($result['body']['newestAt'] ?? '')), )); } } /** * One call to the inbox API. Returns array('code' => int, 'body' => array|null, * 'response' => array), or a WP_Error when the request never got an answer. */ function rayachat_inbox_request($method, $path, array $query = array(), $body = null, $token = null) { $stored = $token === null; $token = $stored ? rayachat_inbox_settings()['token'] : $token; if ($token === '') { return new WP_Error('rayachat_inbox_off', __('This site isn’t allowed to show the inbox yet.', 'raya-ai')); } $url = RAYACHAT_ORIGIN . '/api/integrations/wordpress/' . $path; if ($query) { $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); } $args = array( 'method' => $method, 'timeout' => 15, 'user-agent' => 'Rayachat-WordPress/' . RAYACHAT_VERSION, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', ), ); if ($body !== null) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $response = wp_remote_request($url, $args); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); // Revoked from the dashboard or replaced by a newer connection: say so // until the next «Connect to Rayachat», instead of asking again each time. if ($code === 401 && $stored) { rayachat_inbox_update(array('rejected' => true, 'count' => 0)); } $decoded = json_decode(wp_remote_retrieve_body($response), true); return array('code' => $code, 'body' => is_array($decoded) ? $decoded : null, 'response' => $response); } // Every AJAX call: this admin's nonce and capability, and a site that has the inbox. function rayachat_inbox_ajax_guard() { check_ajax_referer('rayachat_inbox'); if (!rayachat_inbox_can()) { wp_send_json(array('error' => __('You are not allowed to see Rayachat’s inbox.', 'raya-ai')), 403); } if (!rayachat_inbox_active()) { wp_send_json(array('error' => rayachat_inbox_reconnect_text(), 'reconnect' => true), 409); } } function rayachat_inbox_reconnect_text() { return rayachat_inbox_settings()['rejected'] ? __('Rayachat no longer accepts this site’s key, so the inbox is off. An administrator can turn it back on: Rayachat → Settings → “Change chatbot or reconnect”.', 'raya-ai') : __('To see the chatbot’s conversations here, an administrator connects once more: Rayachat → Settings → “Change chatbot or reconnect”, keeping “Inbox and figures in WordPress” ticked.', 'raya-ai'); } // Rayachat's answer, passed to the browser as it came, or why there is none. function rayachat_inbox_relay($result) { if (is_wp_error($result)) { wp_send_json(array('error' => sprintf( /* translators: %s: the network error. */ __('This site could not reach Rayachat: %s', 'raya-ai'), $result->get_error_message() )), 502); } if ($result['code'] === 401) { wp_send_json(array('error' => rayachat_inbox_reconnect_text(), 'reconnect' => true), 409); } if (!is_array($result['body'])) { wp_send_json(array('error' => __('Rayachat sent an answer this plugin can’t read. Please try again in a minute.', 'raya-ai')), 502); } wp_send_json($result['body'], $result['code'] > 0 ? $result['code'] : 502); } // A conversation or message id as Rayachat issues them. function rayachat_inbox_id($value) { $value = is_string($value) ? strtolower(trim($value)) : ''; return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $value) ? $value : ''; } /** * A reply as the admin typed it. Not sanitize_textarea_field(): that drops * percent-encoded bytes, which turns a pasted Persian product link into a * broken one, and anything that looks like a tag. The text goes to Rayachat * as plain text and WordPress never prints it. */ function rayachat_inbox_text($raw) { $text = wp_check_invalid_utf8(is_string($raw) ? $raw : ''); $text = str_replace(array("\r\n", "\r"), "\n", $text); $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text); return trim(function_exists('mb_substr') ? mb_substr($text, 0, 4096) : substr($text, 0, 4096)); } // The WordPress user a reply or a change is signed with, as Rayachat shows it. function rayachat_inbox_author() { $user = wp_get_current_user(); $name = trim(wp_strip_all_tags($user->display_name !== '' ? $user->display_name : $user->user_login)); return function_exists('mb_substr') ? mb_substr($name, 0, 80) : substr($name, 0, 80); } function rayachat_ajax_inbox_list() { rayachat_inbox_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- checked in rayachat_inbox_ajax_guard(). $filter = isset($_GET['filter']) ? sanitize_key(wp_unslash($_GET['filter'])) : 'all'; $query = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : ''; // phpcs:enable $result = rayachat_inbox_request('GET', 'inbox/conversations', array_filter(array( 'filter' => $filter, 'q' => $query, ))); if (!is_wp_error($result) && $result['code'] === 200 && isset($result['body']['rows']) && is_array($result['body']['rows'])) { $result['body']['rows'] = rayachat_inbox_mark_unread($result['body']['rows']); } rayachat_inbox_relay($result); } /** * Sets `unread` on rows Rayachat sent: the customer wrote last, after this * admin last opened the conversation here. Kept per WordPress user, since * WordPress users aren't on the Rayachat team and share one key. */ function rayachat_inbox_mark_unread(array $rows) { $seen = rayachat_inbox_seen(); $since = gmdate('Y-m-d\TH:i:s', time() - RAYACHAT_INBOX_NEW_SECONDS); foreach ($rows as $index => $row) { $at = is_array($row) && is_string($row['customerAt'] ?? null) ? $row['customerAt'] : ''; $id = is_array($row) && is_string($row['id'] ?? null) ? $row['id'] : ''; // Both are Rayachat's ISO times, so they compare as strings. $rows[$index]['unread'] = $at !== '' && $at > ($seen[$id] ?? $since); } return $rows; } function rayachat_inbox_seen() { $seen = get_user_meta(get_current_user_id(), RAYACHAT_INBOX_SEEN_META, true); return is_array($seen) ? $seen : array(); } /** * Remembers the newest customer message a thread showed this admin. Only * moves forward, so a poll that brings nothing new writes nothing. */ function rayachat_inbox_remember_seen(array $body) { $id = rayachat_inbox_id($body['conversation']['id'] ?? ''); $latest = ''; foreach ((array) ($body['messages'] ?? array()) as $message) { if (is_array($message) && ($message['from'] ?? '') === 'customer' && is_string($message['at'] ?? null) && $message['at'] > $latest) { $latest = $message['at']; } } $seen = rayachat_inbox_seen(); if ($id === '' || $latest === '' || ($seen[$id] ?? '') >= $latest) { return; } $seen[$id] = $latest; // The oldest marks go first: a conversation that old reads as read anyway. arsort($seen); update_user_meta(get_current_user_id(), RAYACHAT_INBOX_SEEN_META, array_slice($seen, 0, 300, true)); } function rayachat_ajax_inbox_thread() { rayachat_inbox_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- checked in rayachat_inbox_ajax_guard(). $id = rayachat_inbox_id(isset($_GET['id']) ? sanitize_text_field(wp_unslash($_GET['id'])) : ''); $before = rayachat_inbox_id(isset($_GET['before']) ? sanitize_text_field(wp_unslash($_GET['before'])) : ''); $after = isset($_GET['after']) ? sanitize_text_field(wp_unslash($_GET['after'])) : ''; // phpcs:enable if ($id === '') { wp_send_json(array('error' => 'not found'), 404); } $query = array(); if ($before !== '') { $query['before'] = $before; } elseif (preg_match('/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/', $after)) { $query['after'] = $after; } $result = rayachat_inbox_request('GET', 'inbox/conversations/' . $id, $query); if ($before === '' && !is_wp_error($result) && $result['code'] === 200 && is_array($result['body'])) { rayachat_inbox_remember_seen($result['body']); } rayachat_inbox_relay($result); } function rayachat_ajax_inbox_reply() { rayachat_inbox_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in rayachat_inbox_ajax_guard(). $id = rayachat_inbox_id(isset($_POST['id']) ? sanitize_text_field(wp_unslash($_POST['id'])) : ''); $seen = rayachat_inbox_id(isset($_POST['seen']) ? sanitize_text_field(wp_unslash($_POST['seen'])) : ''); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by rayachat_inbox_text(). $message = rayachat_inbox_text(isset($_POST['message']) ? wp_unslash($_POST['message']) : ''); // phpcs:enable if ($id === '' || $message === '') { wp_send_json(array('error' => __('Write a reply first.', 'raya-ai')), 400); } $body = array('message' => $message, 'by' => rayachat_inbox_author()); if ($seen !== '') { $body['seenAssigneeId'] = $seen; } rayachat_inbox_relay(rayachat_inbox_request('POST', 'inbox/conversations/' . $id . '/reply', array(), $body)); } // «This answer was wrong»: the bot's reply and what it should have said, saved in Rayachat as an approved answer. function rayachat_ajax_inbox_correct() { rayachat_inbox_ajax_guard(); if (!current_user_can(rayachat_inbox_correction_capability())) { wp_send_json(array('error' => __('Only an administrator can teach the bot.', 'raya-ai')), 403); } // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in rayachat_inbox_ajax_guard(). $id = rayachat_inbox_id(isset($_POST['id']) ? sanitize_text_field(wp_unslash($_POST['id'])) : ''); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by rayachat_inbox_text(). $question = rayachat_inbox_text(isset($_POST['question']) ? wp_unslash($_POST['question']) : ''); $answer = rayachat_inbox_text(isset($_POST['answer']) ? wp_unslash($_POST['answer']) : ''); // phpcs:enable if ($id === '' || $question === '' || $answer === '') { wp_send_json(array('error' => __('Write the customer’s question and the right answer.', 'raya-ai')), 400); } rayachat_inbox_relay(rayachat_inbox_request('POST', 'inbox/corrections', array(), array( 'messageId' => $id, 'question' => $question, 'answer' => $answer, ))); } function rayachat_ajax_inbox_update() { rayachat_inbox_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in rayachat_inbox_ajax_guard(). $id = rayachat_inbox_id(isset($_POST['id']) ? sanitize_text_field(wp_unslash($_POST['id'])) : ''); $status = isset($_POST['status']) ? sanitize_key(wp_unslash($_POST['status'])) : ''; $paused = isset($_POST['botPaused']) ? sanitize_key(wp_unslash($_POST['botPaused'])) : ''; // phpcs:enable $body = array('by' => rayachat_inbox_author()); if (in_array($status, array('open', 'waiting', 'resolved'), true)) { $body['status'] = $status; } if ($paused === '1' || $paused === '0') { $body['botPaused'] = $paused === '1'; } if ($id === '' || count($body) < 2) { wp_send_json(array('error' => 'nothing to change'), 400); } rayachat_inbox_relay(rayachat_inbox_request('PATCH', 'inbox/conversations/' . $id, array(), $body)); } function rayachat_ajax_inbox_canned() { rayachat_inbox_ajax_guard(); rayachat_inbox_relay(rayachat_inbox_request('GET', 'inbox/canned-replies')); } function rayachat_ajax_inbox_summary() { rayachat_inbox_ajax_guard(); $summary = rayachat_inbox_summary(); if (is_array($summary)) { wp_send_json(rayachat_inbox_summary_for_user($summary)); } rayachat_inbox_relay($summary); } // The summary is kept for every admin alike; whether a row is unread is each admin's own. function rayachat_inbox_summary_for_user(array $summary) { if (isset($summary['waiting']['rows']) && is_array($summary['waiting']['rows'])) { $summary['waiting']['rows'] = rayachat_inbox_mark_unread($summary['waiting']['rows']); } if (isset($summary['recent']) && is_array($summary['recent'])) { $summary['recent'] = rayachat_inbox_mark_unread($summary['recent']); } return $summary; } /** * The dashboard widget's figures: kept a minute, so the dashboard opening * again draws them at once. Returns the summary, or what the request * returned when it isn't one. */ function rayachat_inbox_summary() { $cached = get_transient('rayachat_summary'); if (is_array($cached)) { return $cached; } $result = rayachat_inbox_request('GET', 'inbox/summary'); if (is_wp_error($result) || $result['code'] !== 200 || !is_array($result['body'])) { return $result; } set_transient('rayachat_summary', $result['body'], RAYACHAT_SUMMARY_SECONDS); if (isset($result['body']['waiting']['count'])) { rayachat_inbox_update(array('count' => absint($result['body']['waiting']['count']), 'checked' => time())); } return $result['body']; } /** * A customer's photo, voice note or PDF, passed through from Rayachat. Only * the types the inbox draws, each sent so the browser can't run it. */ function rayachat_ajax_inbox_file() { rayachat_inbox_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- checked in rayachat_inbox_ajax_guard(). $message = rayachat_inbox_id(isset($_GET['message']) ? sanitize_text_field(wp_unslash($_GET['message'])) : ''); $index = isset($_GET['i']) ? absint(wp_unslash($_GET['i'])) : 0; // phpcs:enable if ($message === '') { status_header(404); exit; } $result = rayachat_inbox_request('GET', 'inbox/attachments/' . $message, array('i' => $index)); $types = array('image/jpeg', 'image/png', 'image/webp', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'application/pdf'); $type = is_wp_error($result) ? '' : strtolower(trim(explode(';', (string) wp_remote_retrieve_header($result['response'], 'content-type'))[0])); if (is_wp_error($result) || $result['code'] !== 200 || !in_array($type, $types, true)) { status_header(is_wp_error($result) ? 502 : 404); exit; } nocache_headers(); header('Content-Type: ' . $type); header('Content-Disposition: ' . ($type === 'application/pdf' ? 'attachment; filename="file.pdf"' : 'inline')); header('X-Content-Type-Options: nosniff'); header("Content-Security-Policy: default-src 'none'; sandbox"); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the file's bytes, of a type checked above. echo wp_remote_retrieve_body($result['response']); exit; } /** * The menu's count, refreshed while any admin page is open: the page's * heartbeat asks, and Rayachat is asked at most once a minute however many * admins are on. */ function rayachat_inbox_heartbeat($response, $data) { if (empty($data['rayachat_inbox']) || !rayachat_inbox_active() || !rayachat_inbox_can()) { return $response; } if ((int) rayachat_inbox_settings()['checked'] < time() - RAYACHAT_INBOX_BADGE_SECONDS) { // It marks the time first, so admins whose heartbeats arrive meanwhile don't ask too. rayachat_inbox_refresh_badge(); } $inbox = rayachat_inbox_settings(); $response['rayachat_inbox'] = array( 'count' => (int) $inbox['count'], 'label' => rayachat_digits(number_format_i18n((int) $inbox['count'])), 'text' => rayachat_inbox_waiting_text((int) $inbox['count']), 'newest' => (string) $inbox['newest'], ); return $response; } // What the bubble says to a screen reader. function rayachat_inbox_waiting_text($count) { /* translators: %s: number of customers. */ return sprintf(_n('%s customer waiting for you', '%s customers waiting for you', $count, 'raya-ai'), rayachat_digits(number_format_i18n($count))); } // The count beside the menu items, in WordPress's own bubble. function rayachat_inbox_bubble() { $count = rayachat_inbox_active() ? (int) rayachat_inbox_settings()['count'] : 0; // WordPress hides a bubble whose class says count-0; the heartbeat changes both. return sprintf( ' %3$s', $count, esc_html(rayachat_digits(number_format_i18n($count))), esc_html(rayachat_inbox_waiting_text($count)) ); }