__('The chatbot is temporarily unavailable. Please check back next month.', 'resolver-ai-chatbot')], 503); } // Honeypot: HTML field must be absent (bots that scrape and submit HTML fields will populate it). if (! empty($_POST[ $hp_fields['hp'] ])) { wp_send_json_error(['message' => __('Invalid request.', 'resolver-ai-chatbot')], 400); } // Honeypot: JS token must be present (bots that don't execute JavaScript won't include it). if (empty($_POST[ $hp_fields['js'] ]) || $_POST[ $hp_fields['js'] ] !== '1') { wp_send_json_error(['message' => __('Invalid request.', 'resolver-ai-chatbot')], 400); } $message = RSVL_Ajax::plain_text('message'); $session_id = RSVL_Ajax::session_id('session_id'); // The page the chat started on, as the widget reports it. The request // is public and the URL is shown and linked in the admin's chat // details, so only a page on this site is kept: wp_validate_redirect() // checks its host against the site's, and any hosts added through the // allowed_redirect_hosts filter, as multi-domain setups do. $page_url = wp_validate_redirect(RSVL_Ajax::url('page_url'), ''); if (empty($message)) { wp_send_json_error(['message' => __('Message cannot be empty.', 'resolver-ai-chatbot')], 400); } if (mb_strlen($message) > 1000) { wp_send_json_error(['message' => __('Your message is too long. Please keep it under 1,000 characters.', 'resolver-ai-chatbot')], 400); } // A blocked message isn't logged: every request refused here would // otherwise add a row to the chat log, however fast they came. $this->guard_session($session_id, 'message'); // Enforce a minimum 1-second gap between messages, keyed on the client // IP rather than the session. session_id arrives in $_POST, so a caller // that sends a fresh one per request would never trip a session-keyed // check — it would only ever slow down honest clients. $last_key = 'resolver_last_' . $this->rate_limit_id(); $last_ts = (int) get_transient($last_key); if ($last_ts && (time() - $last_ts) < 1) { wp_send_json_error(['message' => __('Please slow down.', 'resolver-ai-chatbot')], 429); } set_transient($last_key, time(), 60); // Get the configured provider. $provider_id = resolver_get_setting('chat_provider'); $provider = RSVL_Provider::get_provider($provider_id); if (! $provider) { $msg = __('Chat provider not configured.', 'resolver-ai-chatbot'); $this->log_message($session_id, 'assistant', $msg, $page_url, true); wp_send_json_error(['message' => $msg, 'session_ts' => $this->session_timestamp($session_id)], 500); } // Get the model. $model = resolver_get_setting('chat_model'); if (empty($model)) { $model = array_key_first($provider->get_available_models()); } // Search the training data. $search = new RSVL_Search(); $results = $search->search($message); $blocks = $search->build_context_blocks($results); $query_vector = $search->get_last_query_vector(); /** * Filter whether a visitor's message asks to talk to a person rather * than the chatbot. Stored with the message for the Chats screen. * * @param bool $is_request Default false. * @param array $query_vector The message's embedding, from the training data search. * @param string $message The message. */ $human_request = $query_vector ? (bool) apply_filters('resolver_is_human_request', false, $query_vector, $message) : false; // Build the system prompt. $system_prompt = self::build_system_prompt($blocks); // The conversation so far, then the new message. $messages = $this->conversation_history($session_id); $messages[] = [ 'role' => 'user', 'content' => $message, ]; // Save user message to conversation log. $user_message_id = $this->log_message($session_id, 'user', $message, $page_url, false, $human_request); // Set up streaming response. header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); // Disable PHP-level compression and buffering so chunks reach the client immediately. @ini_set('zlib.output_compression', 'Off'); while (ob_get_level() > 0) { ob_end_clean(); } ob_implicit_flush(true); // Confirm the stored user message even if the visitor disconnects // before the provider finishes. Rejected sends never advance expiry. echo 'data: ' . wp_json_encode(['user_message_id' => $user_message_id, 'session_ts' => $this->session_timestamp($session_id)]) . "\n\n"; flush(); $full_response = ''; $result = $provider->stream_chat_message( $system_prompt, $messages, $model, function (string $chunk) use (&$full_response) { $full_response .= $chunk; echo 'data: ' . wp_json_encode(['content' => $chunk]) . "\n\n"; if (ob_get_level() > 0) { ob_flush(); } flush(); } ); if (is_wp_error($result)) { // The API's own message is kept in the chat log, and shown to the // site's admins, but never to visitors: it can name the account or // model, or show part of the API key. $error_text = $result->get_error_message(); echo 'data: ' . wp_json_encode(['error' => current_user_can('manage_options') ? $error_text : self::visitor_error()]) . "\n\n"; flush(); $this->log_message($session_id, 'assistant', $error_text, $page_url, true); } elseif (is_array($result) && $result['total_tokens'] > 0) { RSVL_Conversations::log_token_usage( 'chat', $provider_id, $model, $result['prompt_tokens'], $result['completion_tokens'], $result['total_tokens'], $session_id ); RSVL_Token_Budget::maybe_send_alert(); } // Log the assistant response first so we have the message ID. $message_id = 0; if (! empty($full_response)) { $message_id = $this->log_message($session_id, 'assistant', $full_response, $page_url); } // Send the done signal with the message ID so the client can enable rating. echo 'data: ' . wp_json_encode(['done' => true, 'message_id' => $message_id, 'session_ts' => $this->session_timestamp($session_id)]) . "\n\n"; flush(); exit; } /** * Return the stored messages for a session so the widget can restore history on page load. */ public function handle_load_session(): void { $session_id = RSVL_Ajax::session_id('session_id'); $last_message_at = $this->guard_session($session_id, 'load'); $conversations = new RSVL_Conversations(); $messages = $conversations->get_conversation_messages($session_id); // Errors are logged as the API reported them, for the Chats screen. // A visitor reloading their chat gets the plain message instead. if (! current_user_can('manage_options')) { foreach ($messages as &$message) { if ($message['is_error']) { $message['message'] = self::visitor_error(); } } unset($message); } wp_send_json_success(['messages' => $messages, 'session_ts' => null === $last_message_at ? null : $last_message_at * 1000]); } /** * Store a thumbs-up or thumbs-down rating on a specific assistant message. * * Only in the session the request names. The endpoint is public and * message IDs are sequential, so without it anyone could rate any * conversation's messages. A session ID is random and known only to the * visitor whose browser holds it. */ public function handle_rate_message(): void { $message_id = RSVL_Ajax::int('message_id'); $session_id = RSVL_Ajax::session_id('session_id'); $rating = RSVL_Ajax::enum('rating', ['up', 'down', ''], ''); if (! $message_id || '' === $session_id || ! in_array($rating, ['up', 'down', ''], true)) { wp_send_json_error(__('Invalid request.', 'resolver-ai-chatbot')); } $this->guard_session($session_id, 'rate'); global $wpdb; $table = RSVL_Schema::chats_table(); $updated = $wpdb->update( $table, ['rating' => $rating ?: null], ['id' => $message_id, 'role' => 'assistant', 'session_id' => $session_id], ['%s'], ['%d', '%s', '%s'] ); if (false === $updated) { wp_send_json_error(__('Invalid request.', 'resolver-ai-chatbot')); } wp_send_json_success(); } /** * The chat's earlier messages, for the model, from the chat log rather * than the browser: a visitor can only continue what was actually said * in their chat, not add turns of their own, such as replies the chatbot * never gave. The session ID is random and known only to their browser. * * @return array */ private function conversation_history(string $session_id): array { $history = (new RSVL_Conversations())->get_recent_history($session_id, self::HISTORY_MESSAGES); // The conversation a model is sent starts with the visitor, and the // most recent messages can start with a reply. while ($history && 'assistant' === $history[0]['role']) { array_shift($history); } return $history; } /** * Guard every widget route before reading or changing a conversation. * Only stored messages extend the 24-hour window; reads and ratings don't. * A new ID can start a chat or load empty history, but has nothing to rate. */ private function guard_session(string $session_id, string $action): ?int { if ('' === $session_id) { wp_send_json_error(['message' => __('Invalid session ID.', 'resolver-ai-chatbot')], 400); } if ('message' === $action && $this->is_rate_limited()) { wp_send_json_error(['message' => __('Too many messages. Please wait a moment.', 'resolver-ai-chatbot')], 429); } $last_message_at = (new RSVL_Conversations())->get_session_last_message_at($session_id); if (null !== $last_message_at && time() - $last_message_at >= DAY_IN_SECONDS) { wp_send_json_error(['code' => 'session_expired', 'message' => __('This conversation has expired.', 'resolver-ai-chatbot')], 410); } if (null === $last_message_at && 'rate' === $action) { wp_send_json_error(['message' => __('Invalid session ID.', 'resolver-ai-chatbot')], 400); } return $last_message_at; } /** The server-confirmed latest message time, in milliseconds for the widget. */ private function session_timestamp(string $session_id): ?int { $last_message_at = (new RSVL_Conversations())->get_session_last_message_at($session_id); return null === $last_message_at ? null : $last_message_at * 1000; } /** * Check the visitor's allowance for sending messages. * * @return bool */ private function is_rate_limited(): bool { $limit = (int) apply_filters('resolver_rate_limit', resolver_get_setting('rate_limit')); if ($limit <= 0) { return false; } $key = 'resolver_rate_' . $this->rate_limit_id(); $data = get_transient($key); $now = time(); // A new minute starts once the last one is over, whatever the stored // expiry says: a transient saved with an expiry of 0 never expires. if (! is_array($data) || $now - (int) ($data['start'] ?? 0) >= MINUTE_IN_SECONDS) { set_transient($key, ['count' => 1, 'start' => $now], MINUTE_IN_SECONDS); return false; } if ($data['count'] >= $limit) { return true; } // What's left of the minute: at least a second, since it isn't over. $data['count']++; set_transient($key, $data, MINUTE_IN_SECONDS - ($now - (int) $data['start'])); return false; } /** * Who the rate limits count a message against: the visitor's address, * hashed, with an IPv6 address counted by its /64 network. A connection * is given a whole /64, so counting each address in it separately would * give one visitor billions of limits. */ private function rate_limit_id(): string { $ip = $this->get_client_ip(); $packed = '' !== $ip ? inet_pton($ip) : false; if (false !== $packed && 16 === strlen($packed)) { // An IPv4 address written as IPv6 (::ffff:192.0.2.1) is counted as // itself; its /64 would be the one every such address shares. $ip = str_starts_with($packed, str_repeat("\0", 10) . "\xff\xff") ? inet_ntop(substr($packed, 12)) : inet_ntop(substr($packed, 0, 8) . str_repeat("\0", 8)) . '/64'; } return resolver_hash_ip($ip); } /** * The visitor's IP address, for rate limiting. * * REMOTE_ADDR, which the visitor can't set. X-Forwarded-For only counts * when REMOTE_ADDR is a private or loopback address, i.e. the request came * through a proxy on the site's own network or server. It's then read from * the right, past the proxies' own private addresses, to the first public * one: the address the proxy saw. Anything the visitor put in the header * sits to the left of that, so it's never used. */ private function get_client_ip(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? self::valid_ip(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) : ''; if ('' !== $ip && ! self::is_public_ip($ip) && ! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $forwarded = explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']))); foreach (array_reverse($forwarded) as $hop) { $hop = self::valid_ip(trim($hop)); if ('' === $hop) { break; // Not an address, so nothing further left can be trusted. } if (self::is_public_ip($hop)) { $ip = $hop; break; } } } /** * Filter the visitor's IP address used for rate limiting. Sites behind * a CDN or proxy on the public internet, such as Cloudflare, can * return the address it passes on, once they've checked the request * came through it. * * @param string $ip The address found as described above, or '' when none. */ return (string) apply_filters('resolver_client_ip', $ip); } /** * Log a message to the conversations table and return the new row ID. */ private function log_message(string $session_id, string $role, string $message, string $page_url = '', bool $is_error = false, bool $human_request = false): int { global $wpdb; // Everything logged here is plain text: the visitor's message, the // model's reply, or an error, which can come from the provider's API. $wpdb->insert( RSVL_Schema::chats_table(), [ 'session_id' => $session_id, 'role' => $role, 'message' => resolver_sanitize_plain_text($message), 'page_url' => $page_url, 'is_error' => $is_error ? 1 : 0, 'human_request' => $human_request ? 1 : 0, 'user_id' => get_current_user_id() ?: null, 'is_admin' => current_user_can('manage_options') ? 1 : 0, 'created_at' => gmdate('Y-m-d H:i:s'), ], ['%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s'] ); return (int) $wpdb->insert_id; } /** * Build the system prompt with RAG context. * * Public and static so the dev-tools plugin can render the exact prompt a * chat would send without instantiating this class — the constructor * registers AJAX handlers — and without keeping its own copy that could * drift from this one. * * @param array $context_blocks The relevant content blocks. * @return string The system prompt. */ public static function build_system_prompt(array $context_blocks): string { // In the site's language, like the training text, whoever is chatting: // chats go through admin-ajax.php, where a logged-in admin gets their // own language rather than the site's. $switched_locale = switch_to_locale(get_locale()); try { // Headings match the tab labels under Training > Instructions, so // the prompt reads the same way the editor that produced it does. // prompt_base is intentionally headingless — it opens the prompt. $sections = [ 'prompt_base' => '', 'prompt_identity' => __('Identity & Purpose', 'resolver-ai-chatbot'), 'prompt_rules' => __('Rules & Limits', 'resolver-ai-chatbot'), 'prompt_tone' => __('Tone & Style', 'resolver-ai-chatbot'), 'prompt_fallback' => __('Human Requests', 'resolver-ai-chatbot'), ]; $prompt = ''; foreach ($sections as $key => $label) { $value = resolver_get_setting($key); if (empty($value)) { continue; } if ($key === 'prompt_base') { $prompt .= $value . "\n\n"; } else { $prompt .= "## {$label}\n" . $value . "\n\n"; } } /* translators: Heading in the instructions sent to the AI model, over the website text it answers from. Not shown to visitors. */ $prompt .= '## ' . __('Website content', 'resolver-ai-chatbot') . "\n"; if (! empty($context_blocks)) { foreach ($context_blocks as $block) { /* translators: %s: URL of the page the text below it comes from. Sent to the AI model, not shown to visitors. */ $prompt .= sprintf(__('Source: %s', 'resolver-ai-chatbot'), $block['source_url']) . "\n"; $prompt .= $block['content'] . "\n\n"; } } else { /* translators: Sent to the AI model when the website has no text matching the visitor's question. Not shown to visitors. */ $prompt .= __('No relevant content was found for this question.', 'resolver-ai-chatbot') . "\n"; } return $prompt; } finally { if ($switched_locale) { restore_previous_locale(); } } } /** What a visitor sees when a reply fails, instead of the API's error. */ private static function visitor_error(): string { return __("Sorry, I couldn't reply just now. Please try again in a moment.", 'resolver-ai-chatbot'); } /** $ip when it's an IPv4 or IPv6 address, or ''. */ private static function valid_ip(string $ip): string { return false !== filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; } /** Whether an address is on the public internet: not private, loopback or reserved. */ private static function is_public_ip(string $ip): bool { return false !== filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); } /** * The chat widget's public endpoints. * * These are the plugin's only unauthenticated surface, so each one opts in * twice and explicitly: 'public' => true to reach logged-out visitors and * 'cap' => null to waive the capability check. The chat nonce is checked * for logged-in users only (see RSVL_Ajax::guard()): cached pages keep * serving logged-out visitors a nonce long expired. Their protection is * session expiry, plus handle_message()'s IP rate limit, honeypot and * token-budget checks. A known session ID is a bearer secret * only while its latest stored message is less than 24 hours old. * * @return array */ private static function ajax_routes(): array { $public = ['nonce' => 'chat', 'public' => true, 'cap' => null, 'nonce_logged_in_only' => true]; return [ 'resolver_chat_message' => $public + ['method' => 'handle_message'], 'resolver_rate_message' => $public + ['method' => 'handle_rate_message'], 'resolver_load_session' => $public + ['method' => 'handle_load_session'], ]; } }