PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.8
MxChat – AI Chatbot & Content Generation for WordPress v3.2.8
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / includes / class-mxchat-integrator.php

class-mxchat-integrator.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.8, at includes/class-mxchat-integrator.php

11,694 lines 474.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 class MxChat_Integrator {
7 private $options;
8 private $prompts_options;
9 private $chat_count;
10 private $fallbackResponse;
11 private $productCardHtml;
12 private $word_handler;
13 private $last_similarity_analysis = null;
14 private $current_valid_urls = [];
15 private $last_vectorstore_error = null;
16 private $is_streaming = false; // ADDED: Track if current request is streaming
17 private $streaming_headers_sent = false; // Track if streaming headers have been sent
18
19 /**
20 * Setup streaming headers - call this right before actually streaming
21 * This delays header setup to allow actions/forms to return JSON responses
22 */
23 /**
24 * Auto-retry wrapper around wp_remote_post for chat-send provider calls.
25 *
26 * Retries up to twice (750ms then 2000ms backoff) when the upstream provider
27 * returns a TRANSIENT error: WP timeout, 429, 502, 503, 504, or a provider-
28 * specific "overloaded" / "rate limit" body string. Returns immediately on
29 * permanent errors (401/403/404/422) so misconfiguration surfaces fast.
30 *
31 * Drop-in replacement for wp_remote_post — returns the same shape
32 * (WP_Error or response array) so the caller's existing error-handling
33 * code path is unchanged.
34 *
35 * STREAMING PATH NOTE: this helper is ONLY for non-streaming chat-send
36 * paths (the *_response_openai / *_response_claude / etc functions).
37 * For the *_stream variants, the cURL initial-connect happens inside a
38 * read-chunks loop — retrying there safely (without re-emitting partial
39 * stream chunks to the client) is a separate problem. Streaming paths
40 * are NOT wrapped in this build; tracked as a follow-on.
41 *
42 * Honors the `mxchat_options['auto_retry_on_transient_error']` toggle
43 * (default true). When false, behavior is identical to plain wp_remote_post.
44 */
45 private function mxchat_provider_call_with_retry($url, $args, $provider_hint = '') {
46 $opts = is_array($this->options ?? null) ? $this->options : array();
47 $enabled = !isset($opts['auto_retry_on_transient_error']) ||
48 (string) $opts['auto_retry_on_transient_error'] !== '0';
49
50 if (!$enabled) {
51 return wp_remote_post($url, $args);
52 }
53
54 $backoffs = array(0, 750, 2000); // ms — first attempt 0, then retry waits
55 $last_response = null;
56
57 foreach ($backoffs as $i => $delay_ms) {
58 if ($delay_ms > 0) {
59 usleep($delay_ms * 1000);
60 }
61 $response = wp_remote_post($url, $args);
62 $last_response = $response;
63
64 if (!$this->mxchat_is_transient_provider_error($response, $provider_hint)) {
65 return $response;
66 }
67
68 if (defined('WP_DEBUG') && WP_DEBUG) {
69 $code_for_log = is_wp_error($response) ? 'wp_error:' . $response->get_error_code()
70 : (int) wp_remote_retrieve_response_code($response);
71 error_log(sprintf(
72 '[MxChat] Transient provider error (provider=%s, attempt=%d/3, status=%s). %s',
73 $provider_hint ?: 'unknown',
74 $i + 1,
75 $code_for_log,
76 ($i + 1) < count($backoffs) ? 'Retrying.' : 'Giving up.'
77 ));
78 }
79 }
80
81 return $last_response;
82 }
83
84 /**
85 * Returns true if a wp_remote_post response represents a TRANSIENT
86 * provider error worth retrying. Conservative — only retries on signals
87 * that are very likely to clear within a few seconds.
88 *
89 * Transient signals:
90 * - WP_Error with timeout / connection / dns / ssl
91 * - HTTP 429, 502, 503, 504
92 * - Provider-specific overload bodies (gemini "overloaded", openai
93 * "server_error", anthropic "overloaded_error", xai/grok "Rate limit")
94 *
95 * NOT transient (return false — fail-fast):
96 * - 200/2xx (success)
97 * - 401, 403, 404, 422 (auth / config errors — retrying wastes the
98 * budget; the user needs to fix something)
99 * - Any other 4xx (assume permanent unless explicitly listed above)
100 * - 5xx other than the four listed above (e.g. 500 generic server error
101 * is often a malformed request on our side, not a transient outage)
102 */
103 private function mxchat_is_transient_provider_error($response, $provider_hint = '') {
104 if (is_wp_error($response)) {
105 $code = $response->get_error_code();
106 return in_array($code, array('http_request_failed', 'connection_failed', 'connection_timeout'), true)
107 || stripos((string) $response->get_error_message(), 'timed out') !== false
108 || stripos((string) $response->get_error_message(), 'timeout') !== false;
109 }
110
111 $status = (int) wp_remote_retrieve_response_code($response);
112 if (in_array($status, array(429, 502, 503, 504), true)) {
113 return true;
114 }
115 if ($status >= 200 && $status < 300) {
116 return false;
117 }
118 // Permanent 4xx that should fail fast — even with no body.
119 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
120 return false;
121 }
122
123 // Provider-specific body inspection for the cases where the upstream
124 // returns 200 with an error envelope (gemini does this for overload).
125 $body = (string) wp_remote_retrieve_body($response);
126 if ($body === '') {
127 return false;
128 }
129 $lower = strtolower($body);
130 $hint = strtolower((string) $provider_hint);
131
132 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
133 || strpos($lower, 'high demand') !== false
134 || strpos($lower, 'model is overloaded') !== false)) {
135 return true;
136 }
137 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
138 || strpos($lower, '"type":"server_error"') !== false
139 || strpos($lower, '"code":"server_error"') !== false)) {
140 return true;
141 }
142 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
143 || strpos($lower, 'overloaded_error') !== false)) {
144 return true;
145 }
146 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
147 return true;
148 }
149
150 return false;
151 }
152
153 /**
154 * Streaming-path classifier: same rules as mxchat_is_transient_provider_error
155 * but takes a raw (http_code, body, provider_hint, curl_errno) tuple as
156 * captured during a cURL streaming exec. cURL's WRITEFUNCTION/HEADERFUNCTION
157 * collect status separately from a plain wp_remote_post array shape, so the
158 * non-streaming helper above can't be called directly. This delegate keeps
159 * the classification rules identical across both paths.
160 */
161 private function mxchat_is_transient_provider_error_raw($http_code, $body, $provider_hint = '', $curl_errno = 0) {
162 if ($curl_errno) {
163 // cURL transport-level error (timeout, connection failure, DNS, etc.)
164 // Match the same WP_Error timeout/connection signals the array variant treats as transient.
165 return in_array($curl_errno, array(
166 CURLE_OPERATION_TIMEDOUT,
167 CURLE_COULDNT_CONNECT,
168 CURLE_COULDNT_RESOLVE_HOST,
169 CURLE_SSL_CONNECT_ERROR,
170 CURLE_GOT_NOTHING,
171 CURLE_SEND_ERROR,
172 CURLE_RECV_ERROR,
173 ), true);
174 }
175
176 $status = (int) $http_code;
177 if (in_array($status, array(429, 502, 503, 504), true)) {
178 return true;
179 }
180 if ($status >= 200 && $status < 300) {
181 return false;
182 }
183 if (in_array($status, array(401, 403, 404, 405, 422), true)) {
184 return false;
185 }
186
187 $body = (string) $body;
188 if ($body === '') {
189 return false;
190 }
191 $lower = strtolower($body);
192 $hint = strtolower((string) $provider_hint);
193
194 if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
195 || strpos($lower, 'high demand') !== false
196 || strpos($lower, 'model is overloaded') !== false)) {
197 return true;
198 }
199 if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
200 || strpos($lower, '"type":"server_error"') !== false
201 || strpos($lower, '"code":"server_error"') !== false)) {
202 return true;
203 }
204 if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
205 || strpos($lower, 'overloaded_error') !== false)) {
206 return true;
207 }
208 if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
209 return true;
210 }
211
212 return false;
213 }
214
215 /**
216 * Whether transient-error auto-retry is enabled in admin settings.
217 * Default true unless explicitly set to '0'. Used by both wp_remote_post
218 * (mxchat_provider_call_with_retry) and cURL streaming paths.
219 */
220 private function mxchat_retry_enabled() {
221 $opts = is_array($this->options ?? null) ? $this->options : array();
222 return !isset($opts['auto_retry_on_transient_error']) ||
223 (string) $opts['auto_retry_on_transient_error'] !== '0';
224 }
225
226 private function setup_streaming_headers() {
227 if ($this->streaming_headers_sent || headers_sent()) {
228 return false;
229 }
230
231 // Disable output buffering
232 while (ob_get_level()) {
233 ob_end_flush();
234 }
235
236 // Set headers for SSE
237 header('Content-Type: text/event-stream');
238 header('Cache-Control: no-cache');
239 header('Connection: keep-alive');
240 header('X-Accel-Buffering: no');
241
242 ob_implicit_flush(true);
243 flush();
244
245 $this->streaming_headers_sent = true;
246 return true;
247 }
248
249 /**
250 * Class constructor
251 */
252 public function __construct() {
253 $this->options = get_option('mxchat_options');
254 $this->prompts_options = get_option('mxchat_prompts_options', array());
255 $this->chat_count = get_option('mxchat_chat_count', 0);
256 $this->word_handler = new MXChat_Word_Handler($this->options);
257
258 // Add all action hooks
259 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
260 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
261 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
262 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
263 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
264
265 // Add the AJAX actions for checking if the pre-chat message was dismissed
266 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
267 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
268 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
269 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
270 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
271 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
272
273 // Add REST API routes registration
274 add_action('rest_api_init', array($this, 'register_routes'));
275 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
276 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
277
278 // Rate limit action - notice we removed the old schedule setup
279 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
280
281 // File upload and handling actions
282 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
283 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
284 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
285 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
286
287 // Word document handling actions
288 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
289 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
290 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
291 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
292 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
293 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
294
295 // Email handling actions
296 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
297 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
298 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
299 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
300
301 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
302 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
303
304 // Testing panel AJAX actions
305 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
306 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
307 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
308 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
309 // Add to your existing constructor, in the section with other AJAX actions:
310 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
311 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
312 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
313 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
314 // Add chat mode checking actions
315 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
316 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
317
318 // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
319 add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
320 add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
321
322 // Auto-email transcript action
323 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
324
325 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
326
327
328 }
329
330 /**
331 * Return a fresh nonce so cached pages can replace the stale one.
332 */
333 public function mxchat_refresh_nonce() {
334 nocache_headers();
335 wp_send_json_success(array('nonce' => wp_create_nonce('mxchat_chat_nonce')));
336 }
337
338 // In your core plugin's check_actions_for_addons method:
339 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
340 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
341
342 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
343
344 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
345
346 return $result;
347 }
348
349 private function mxchat_increment_chat_count() {
350 $chat_count = get_option('mxchat_chat_count', 0);
351 $chat_count++;
352 update_option('mxchat_chat_count', $chat_count);
353 }
354
355 function mxchat_fetch_conversation_history() {
356 if (empty($_POST['session_id'])) {
357 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
358 wp_die();
359 }
360
361 $session_id = sanitize_text_field($_POST['session_id']);
362
363 // SECURITY FIX: Verify session ownership before retrieving data
364 // If IP/user changed, signal frontend to reset session instead of blocking
365 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
366
367 // Check if this session has an owner recorded
368 $session_owner = get_option("mxchat_session_owner_{$session_id}");
369
370 // Update session owner if it changed (e.g. IP changed due to network switch)
371 // The session ID itself is the authentication — if the client has it, they own it
372 if (!$session_owner || $session_owner !== $current_user_identifier) {
373 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
374 }
375
376 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
377 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
378
379 if (empty($history)) {
380 // Even if history is empty, return the chat mode
381 wp_send_json_success([
382 'conversation' => [],
383 'chat_mode' => $chat_mode
384 ]);
385 wp_die();
386 }
387
388 wp_send_json_success([
389 'conversation' => $history,
390 'chat_mode' => $chat_mode
391 ]);
392 wp_die();
393 }
394 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
395 $history = get_option("mxchat_history_{$session_id}", []);
396
397 // Check persistence setting - when OFF, only include messages from current page load
398 $options = get_option('mxchat_options', []);
399 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
400
401 // Filter history when persistence is OFF to match what the user sees
402 if (!$persistence_enabled && $session_start_timestamp > 0) {
403 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
404 // Include messages from this page load onwards
405 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
406 });
407 // Re-index array after filtering
408 $history = array_values($history);
409 }
410
411 $formatted_history = [];
412
413 // Adjusted for code-heavy conversations
414 $max_tokens = 120000; // Context window size
415 $reserved_tokens = 5000; // Space for system prompts + current query
416 $current_token_count = 0;
417
418 // Allowed HTML tags for content sanitization
419 $allowed_tags = [
420 'pre' => ['class' => true],
421 'code' => ['class' => true],
422 'span' => ['class' => true],
423 'div' => ['class' => true],
424 'strong' => [],
425 'em' => []
426 ];
427
428 foreach (array_reverse($history) as $entry) {
429 // Preserve code blocks while sanitizing other HTML
430 $clean_content = wp_kses($entry['content'], $allowed_tags);
431
432 // Detect code blocks in content
433 $has_code = false;
434 // Replace the HTML check with:
435 // Allow messages that contain code blocks or are plain text
436 if (strpos($clean_content, '<pre') === false &&
437 strpos($clean_content, '<code') === false &&
438 $clean_content !== strip_tags($entry['content'])) {
439 continue;
440 }
441
442 // Skip entries that lost significant content during sanitization
443 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
444 continue;
445 }
446
447 // More accurate token estimation (1 token ≈ 4 characters)
448 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
449
450 // Check token budget with the new estimate
451 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
452 // Try to fit partial content if it's the first entry
453 if (empty($formatted_history)) {
454 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
455 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
456 } else {
457 break;
458 }
459 }
460
461 // Add to formatted history
462 $formatted_history[] = [
463 'role' => $entry['role'],
464 'content' => $clean_content
465 ];
466
467 $current_token_count += $token_estimate;
468 }
469
470 // Reverse back to maintain chronological order
471 $formatted_history = array_reverse($formatted_history);
472
473 // Add system message about code context
474 array_unshift($formatted_history, [
475 'role' => 'system',
476 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
477 . 'Maintain formatting and syntax highlighting when referencing code.'
478 ]);
479
480 return $formatted_history;
481 }
482
483 public function register_routes() {
484 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
485
486 // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
487 // so the chat widget never depends on a stale nonce embedded in cached HTML.
488 // Public (no auth), rate-limited (1 call / IP / second via a transient).
489 register_rest_route('mxchat/v1', '/nonce', [
490 'methods' => 'GET',
491 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
492 'permission_callback' => '__return_true',
493 ]);
494
495 register_rest_route('mxchat/v1', '/stream', [
496 'methods' => 'GET',
497 'callback' => [$this, 'mxchat_stream_events'],
498 'permission_callback' => [$this, 'verify_chat_session'],
499 ]);
500
501 register_rest_route('mxchat/v1', '/agent-response', [
502 'methods' => 'POST',
503 'callback' => [$this, 'mxchat_handle_agent_response'],
504 'permission_callback' => [$this, 'verify_slack_request'],
505 ]);
506
507 register_rest_route('mxchat/v1', '/slack-interaction', [
508 'methods' => 'POST',
509 'callback' => [$this, 'handle_slack_interaction'],
510 'permission_callback' => [$this, 'verify_slack_request'],
511 ]);
512
513 register_rest_route('mxchat/v1', '/slack-messages', [
514 'methods' => 'POST',
515 'callback' => [$this, 'handle_slack_messages'],
516 'permission_callback' => [$this, 'verify_slack_request'],
517 ]);
518
519 // Telegram webhook endpoint
520 register_rest_route('mxchat/v1', '/telegram-webhook', [
521 'methods' => 'POST',
522 'callback' => [$this, 'handle_telegram_webhook'],
523 'permission_callback' => [$this, 'verify_telegram_request'],
524 ]);
525
526 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
527 }
528
529 /**
530 * Issue a fresh per-request nonce for chat-send. Returned to the widget which
531 * caches it for the session and includes it on every chat-send / stream-send /
532 * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
533 * we eliminate the entire class of "first-message Access denied" failures that
534 * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
535 * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
536 * lives in the HTML body.
537 *
538 * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
539 * client browser can't be used to flood the nonce-issuance path.
540 *
541 * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
542 * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
543 * backwards-compat window so cached pages still in users' browsers don't break
544 * mid-session.
545 *
546 * @since 3.2.7
547 */
548 public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
549 $ip = '';
550 if (!empty($_SERVER['REMOTE_ADDR'])) {
551 $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
552 }
553 if ($ip !== '') {
554 // Best-effort rate limit. WP transients with sub-second TTL are racy
555 // (parallel bursts can squeak through before set_transient completes);
556 // we use 2s to make the gate slightly more reliable. Real production
557 // rate-limiting at sub-second granularity needs Redis or DB row locks
558 // — out of scope for this endpoint, which is already cheap.
559 $key = 'mxchat_nonce_rl_' . md5($ip);
560 if (get_transient($key)) {
561 return new WP_REST_Response(array(
562 'error' => 'rate_limited',
563 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
564 ), 429);
565 }
566 set_transient($key, 1, 2);
567 }
568
569 return new WP_REST_Response(array(
570 'nonce' => wp_create_nonce('mxchat_chat_send'),
571 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
572 ), 200);
573 }
574
575 /**
576 * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
577 * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
578 * action (inline-localized in older cached HTML). The legacy acceptance is
579 * a 30-day backwards-compat window — to be removed in a follow-up release
580 * after 2026-06-27.
581 *
582 * @param string $posted_nonce
583 * @return bool
584 */
585 public static function mxchat_verify_chat_send_nonce($posted_nonce) {
586 if (!is_string($posted_nonce) || $posted_nonce === '') {
587 return false;
588 }
589 return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
590 || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
591 }
592
593 /**
594 * Verify valid chat session
595 */
596 public function verify_chat_session($request) {
597 $session_id = $request->get_param('session_id');
598 if (empty($session_id)) {
599 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
600 return false;
601 }
602
603 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
604 return $chat_mode === 'agent';
605 }
606
607 /**
608 * Verify request is coming from Slack.
609 *
610 * @param WP_REST_Request $request
611 * @return bool True if valid, false otherwise.
612 */
613 public function verify_slack_request($request) {
614 // Get the Slack signing secret from your plugin options
615 $valid_key = $this->options['live_agent_secret_key'] ?? '';
616
617 if (empty($valid_key)) {
618 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
619 return false;
620 }
621
622 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
623 $slack_signature = $request->get_header('X-Slack-Signature');
624
625 // Verify timestamp to prevent replay attacks
626 if (abs(time() - intval($timestamp)) > 300) {
627 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
628 return false;
629 }
630
631 // Get raw request body from the WP_REST_Request object
632 // (php://input may already be consumed by WordPress at this point)
633 $request_body = $request->get_body();
634
635 // Create the signature base string
636 $sig_basestring = "v0:{$timestamp}:{$request_body}";
637
638 // Calculate expected signature
639 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
640
641 // Compare signatures
642 return hash_equals($my_signature, $slack_signature);
643 }
644
645 /**
646 * Verify request is coming from Telegram.
647 *
648 * @param WP_REST_Request $request
649 * @return bool True if valid, false otherwise.
650 */
651 public function verify_telegram_request($request) {
652 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
653
654 //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
655 //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
656
657 if (empty($secret_token)) {
658 // If no secret is configured, allow the request (for initial setup)
659 //error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
660 return true;
661 }
662
663 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
664 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
665
666 //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
667
668 if (empty($request_token)) {
669 //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
670 return false;
671 }
672
673 // Timing-safe comparison
674 $result = hash_equals($secret_token, $request_token);
675 //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
676 return $result;
677 }
678
679 public function mxchat_stream_events(WP_REST_Request $request) {
680 header('Content-Type: text/event-stream');
681 header('Cache-Control: no-cache');
682 header('Connection: keep-alive');
683
684 $session_id = sanitize_text_field($request->get_param('session_id'));
685 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
686
687 if (empty($session_id)) {
688 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
689 flush();
690 exit;
691 }
692
693 $history = get_option("mxchat_history_{$session_id}", []);
694
695 // Filter only new messages
696 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
697 return !empty($message['id']) && $message['id'] > $last_seen_id;
698 });
699
700 // Send new messages if available
701 if (!empty($new_messages)) {
702 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
703 } else {
704 // Keep the connection alive
705 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
706 }
707 flush();
708 exit;
709 }
710
711
712
713
714 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
715 global $wpdb;
716 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
717 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
718
719 // Check if this is the first message in a new session (before any other database operations)
720 $is_new_session = false;
721 if ($role === 'user') { // Only check for user messages, not bot responses
722 $existing_messages = $wpdb->get_var($wpdb->prepare(
723 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
724 $session_id
725 ));
726 $is_new_session = ($existing_messages == 0);
727
728 // Log for debugging
729 if ($is_new_session) {
730 //error_log("[DEBUG] This is a NEW session - first message");
731 }
732 }
733
734 // SECURITY FIX: Set session ownership for new sessions
735 if ($is_new_session && $role === 'user') {
736 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
737 $session_owner_key = "mxchat_session_owner_{$session_id}";
738
739 // Only set ownership if not already set
740 if (!get_option($session_owner_key)) {
741 update_option($session_owner_key, $current_user_identifier, 'no');
742 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
743 }
744 }
745
746 // 1) Extract agent name if present
747 $agent_name = '';
748 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
749 $agent_name = $matches[1];
750 $message = str_replace("Agent: $agent_name - ", '', $message);
751 $session_meta_key = "mxchat_agent_name_{$session_id}";
752 if (empty(get_option($session_meta_key))) {
753 update_option($session_meta_key, $agent_name);
754 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
755 }
756 }
757
758 // 2) Generate unique message_id
759 $message_id = uniqid();
760 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
761
762 // 3) Determine user_id
763 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
764
765 // 4) Determine user_identifier
766 $user_identifier = $agent_name
767 ? $agent_name
768 : MxChat_User::mxchat_get_user_identifier();
769
770 // 5) Determine displayed_name
771 $user_email = MxChat_User::mxchat_get_user_email();
772 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
773
774 // 6) Check for a saved email in wp_options
775 $email_option_key = "mxchat_email_{$session_id}";
776 $saved_email = get_option($email_option_key);
777 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
778
779 // Check for a saved name in wp_options
780 $name_option_key = "mxchat_name_{$session_id}";
781 $saved_name = get_option($name_option_key);
782 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
783
784 // If found, update DB user_email and user_name
785 if ($saved_email || $saved_name) {
786 $update_data = [];
787 if ($saved_email) {
788 $update_data['user_email'] = $saved_email;
789 }
790 if ($saved_name) {
791 $update_data['user_name'] = $saved_name;
792 }
793
794 if (!empty($update_data)) {
795 $update_res = $wpdb->update(
796 $table_name,
797 $update_data,
798 ['session_id' => $session_id],
799 array_fill(0, count($update_data), '%s'),
800 ['%s']
801 );
802 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
803 }
804 }
805
806 // 7) Save to session history in wp_options
807 $history_key = "mxchat_history_{$session_id}";
808 $history = get_option($history_key, []);
809 $history[] = [
810 'id' => $message_id,
811 'role' => $role,
812 'content' => $message,
813 'timestamp' => round(microtime(true) * 1000),
814 'agent_name' => $displayed_name,
815 ];
816 update_option($history_key, $history, 'no');
817 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
818
819 // 8) Save the message to DB (INSERT)
820 $insert_data = [
821 'user_id' => $user_id,
822 'user_identifier'=> $user_identifier,
823 'user_email' => $saved_email ?: $user_email,
824 'user_name' => $saved_name ?: '', // Add name to insert data
825 'session_id' => $session_id,
826 'role' => $role,
827 'message' => $message,
828 'timestamp' => current_time('mysql', 1),
829 ];
830
831 // IMPROVED: Handle originating page data
832 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
833
834 if ($columns_exist) {
835 if ($is_new_session && $role === 'user') {
836 // For the first user message, set originating page data
837
838 // First check if we have it from the parameter
839 if ($originating_page && !empty($originating_page['url'])) {
840 $insert_data['originating_page_url'] = $originating_page['url'];
841 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
842
843 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
844 }
845 // Otherwise check if it's stored in the instance property
846 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
847 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
848 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
849
850 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
851
852 // Clear after using
853 unset($this->pending_originating_page);
854 }
855 // Fallback to HTTP_REFERER if nothing else is available
856 else if (isset($_SERVER['HTTP_REFERER'])) {
857 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
858 $insert_data['originating_page_url'] = $referer_url;
859
860 // Generate title from URL
861 $parsed_url = parse_url($referer_url);
862 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
863
864 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
865 $insert_data['originating_page_title'] = 'Homepage';
866 } else {
867 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
868 $insert_data['originating_page_title'] = ucwords(trim($title));
869 }
870
871 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
872 }
873
874 // Store for this session so all messages have the same originating page
875 if (!empty($insert_data['originating_page_url'])) {
876 update_option("mxchat_originating_page_{$session_id}", [
877 'url' => $insert_data['originating_page_url'],
878 'title' => $insert_data['originating_page_title']
879 ], 'no');
880 }
881 } else {
882 // For subsequent messages in the session, use the stored originating page
883 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
884 if ($stored_originating && !empty($stored_originating['url'])) {
885 $insert_data['originating_page_url'] = $stored_originating['url'];
886 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
887 }
888 }
889 }
890
891 // Add RAG context if provided (for bot messages)
892 if ($rag_context !== null && $role === 'bot') {
893 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
894 if ($rag_context_column_exists) {
895 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
896 }
897 }
898
899 $wpdb->insert($table_name, $insert_data);
900 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
901
902 // 9) Send notification email if this is the first user message in a new session
903 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
904 $this->send_new_chat_notification($session_id, array(
905 'identifier' => $user_identifier,
906 'email' => $saved_email ?: $user_email,
907 'ip' => $_SERVER['REMOTE_ADDR']
908 ));
909 }
910
911 // 10) Schedule delayed transcript email if enabled and message is from user
912 if ($wpdb->insert_id && $role === 'user') {
913 $this->schedule_delayed_transcript_email($session_id);
914 }
915
916 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
917 return $message_id;
918 }
919
920 private function send_new_chat_notification($session_id, $user_info = array()) {
921 $options = get_option('mxchat_transcripts_options');
922
923 // Check if notifications are enabled
924 if (empty($options['mxchat_enable_notifications'])) {
925 return false;
926 }
927
928 // Get notification email
929 $to = !empty($options['mxchat_notification_email']) ?
930 $options['mxchat_notification_email'] :
931 get_option('admin_email');
932
933 if (!is_email($to)) {
934 return false;
935 }
936
937 // Prepare email content
938 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
939
940 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
941 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
942 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
943
944 $message = sprintf(
945 "A new chat session has started on your website.\n\n" .
946 "Session ID: %s\n" .
947 "User: %s\n" .
948 "Email: %s\n" .
949 "IP Address: %s\n" .
950 "Time: %s\n\n" .
951 "View transcripts: %s",
952 $session_id,
953 $user_identifier,
954 $user_email,
955 $user_ip,
956 current_time('mysql'),
957 admin_url('admin.php?page=mxchat-transcripts')
958 );
959
960 // Send email
961 return wp_mail($to, $subject, $message);
962 }
963
964 /**
965 * Schedule delayed transcript email for a session
966 * Reschedules if a new user message is received
967 */
968 private function schedule_delayed_transcript_email($session_id) {
969 $options = get_option('mxchat_transcripts_options');
970
971 // Check if auto-email is enabled
972 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
973 return;
974 }
975
976 // Get notification email
977 $email = !empty($options['mxchat_notification_email']) ?
978 $options['mxchat_notification_email'] :
979 get_option('admin_email');
980
981 if (!is_email($email)) {
982 return;
983 }
984
985 // Get delay in minutes (default 30)
986 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
987 intval($options['mxchat_auto_email_transcript_delay']) : 30;
988
989 // Clear any existing scheduled event for this session
990 $hook = 'mxchat_send_delayed_transcript';
991 $args = array($session_id);
992 $timestamp = wp_next_scheduled($hook, $args);
993
994 if ($timestamp) {
995 wp_unschedule_event($timestamp, $hook, $args);
996 }
997
998 // Schedule new event
999 $schedule_time = time() + ($delay_minutes * 60);
1000 wp_schedule_single_event($schedule_time, $hook, $args);
1001 }
1002
1003 /**
1004 * Check if chat messages contain contact information (email or phone number)
1005 *
1006 * @param array $messages Array of message objects with 'message' property
1007 * @param object|null $session_data Session data object with user_email property
1008 * @return bool True if contact info found, false otherwise
1009 */
1010 private function chat_contains_contact_info($messages, $session_data = null) {
1011 // Check if session already has a stored email
1012 if ($session_data && !empty($session_data->user_email)) {
1013 return true;
1014 }
1015
1016 // Email regex pattern
1017 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
1018
1019 // Phone number patterns (covers various formats including international, WhatsApp style)
1020 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
1021 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
1022
1023 // Only check user messages (not assistant responses)
1024 foreach ($messages as $msg) {
1025 if ($msg->role !== 'user') {
1026 continue;
1027 }
1028
1029 $message_text = $msg->message;
1030
1031 // Check for email
1032 if (preg_match($email_pattern, $message_text)) {
1033 return true;
1034 }
1035
1036 // Check for phone number (must be at least 7 digits total to avoid false positives)
1037 if (preg_match($phone_pattern, $message_text, $matches)) {
1038 // Count actual digits to avoid matching short numbers
1039 $digits_only = preg_replace('/\D/', '', $matches[0]);
1040 if (strlen($digits_only) >= 7) {
1041 return true;
1042 }
1043 }
1044 }
1045
1046 return false;
1047 }
1048
1049 /**
1050 * Send the delayed transcript email with .txt attachment
1051 */
1052 public function mxchat_send_delayed_transcript($session_id) {
1053 global $wpdb;
1054
1055 $options = get_option('mxchat_transcripts_options');
1056
1057 // Get notification email
1058 $to = !empty($options['mxchat_notification_email']) ?
1059 $options['mxchat_notification_email'] :
1060 get_option('admin_email');
1061
1062 if (!is_email($to)) {
1063 return false;
1064 }
1065
1066 // Get all messages for this session
1067 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1068 $messages = $wpdb->get_results($wpdb->prepare(
1069 "SELECT role, message, timestamp FROM {$table_name}
1070 WHERE session_id = %s
1071 ORDER BY timestamp ASC",
1072 $session_id
1073 ));
1074
1075 if (empty($messages)) {
1076 return false;
1077 }
1078
1079 // Get session metadata
1080 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
1081 $session_data = $wpdb->get_row($wpdb->prepare(
1082 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
1083 $session_id
1084 ));
1085
1086 // Check if contact info is required and if it's present
1087 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
1088 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
1089 // Contact info required but not found - skip sending
1090 return false;
1091 }
1092
1093 // Build transcript content
1094 $transcript_content = "Chat Transcript\n";
1095 $transcript_content .= "================\n\n";
1096 $transcript_content .= "Session ID: " . $session_id . "\n";
1097
1098 if ($session_data) {
1099 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1100 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1101 $transcript_content .= "Started: " . $session_data->created_at . "\n";
1102 }
1103
1104 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
1105
1106 // Add messages
1107 foreach ($messages as $msg) {
1108 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
1109 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
1110 $transcript_content .= $msg->message . "\n\n";
1111 }
1112
1113 // Create temporary file for attachment using WP_Filesystem
1114 $upload_dir = wp_upload_dir();
1115 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
1116 global $wp_filesystem;
1117 if (empty($wp_filesystem)) {
1118 require_once ABSPATH . 'wp-admin/includes/file.php';
1119 WP_Filesystem();
1120 }
1121 $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
1122
1123 // Prepare email
1124 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
1125
1126 $message = "Please find attached the full chat transcript.\n\n";
1127 $message .= "Session ID: {$session_id}\n";
1128
1129 if ($session_data) {
1130 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1131 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1132 }
1133
1134 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
1135
1136 // Send email with attachment
1137 $attachments = array($temp_file);
1138 $result = wp_mail($to, $subject, $message, '', $attachments);
1139
1140 // Clean up temporary file
1141 if (file_exists($temp_file)) {
1142 unlink($temp_file);
1143 }
1144
1145 return $result;
1146 }
1147
1148
1149
1150 public function mxchat_handle_save_email_and_response() {
1151 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
1152 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
1153
1154 nocache_headers();
1155
1156 // Validate nonce
1157 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1158 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
1159 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
1160 wp_die();
1161 }
1162
1163 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1164 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1165 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1166
1167 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
1168
1169 if (empty($session_id) || $session_id === 'null' || empty($email)) {
1170 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
1171 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
1172 wp_die();
1173 }
1174
1175 // Validate name if provided (check if name field is enabled and name is required)
1176 $options = get_option('mxchat_options', []);
1177 $name_field_enabled = isset($options['enable_name_field']) &&
1178 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1179
1180 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
1181 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
1182 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
1183 wp_die();
1184 }
1185
1186 // 1) Always store email in wp_options
1187 $email_option_key = "mxchat_email_{$session_id}";
1188 update_option($email_option_key, $email, 'no');
1189 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
1190
1191 // Store name in wp_options if provided
1192 if (!empty($name)) {
1193 $name_option_key = "mxchat_name_{$session_id}";
1194 update_option($name_option_key, $name, 'no');
1195 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
1196 }
1197
1198 // 2) (Optional) Also store in DB if a row already exists
1199 global $wpdb;
1200 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1201
1202 // Make sure we have a valid placeholder in prepare
1203 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
1204 $session_count = $wpdb->get_var($sql);
1205
1206 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
1207
1208 if ($session_count) {
1209 // Update both user_email and user_name if row(s) exist
1210 if (!empty($name)) {
1211 $update_sql = $wpdb->prepare(
1212 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
1213 $email,
1214 $name,
1215 $session_id
1216 );
1217 } else {
1218 $update_sql = $wpdb->prepare(
1219 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
1220 $email,
1221 $session_id
1222 );
1223 }
1224 $wpdb->query($update_sql);
1225 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
1226 } else {
1227 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
1228 }
1229
1230 // Provide success response (same as original)
1231 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
1232 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
1233 wp_send_json_success(['message' => $bot_message]);
1234 wp_die();
1235 }
1236
1237 public function mxchat_check_email_provided() {
1238 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
1239
1240 nocache_headers();
1241
1242 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
1243 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
1244 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
1245 }
1246
1247 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1248 if (empty($session_id) || $session_id === 'null') {
1249 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
1250 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
1251 }
1252
1253 // Check if the user is logged in
1254 if (is_user_logged_in()) {
1255 $current_user = wp_get_current_user();
1256 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
1257
1258 // Get user's display name for logged in users
1259 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
1260 (!empty($current_user->first_name) ? $current_user->first_name : '');
1261
1262 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
1263 if (!empty($user_name)) {
1264 $response_data['name'] = $user_name;
1265 }
1266
1267 wp_send_json_success($response_data);
1268 }
1269
1270 // Check if name field is required
1271 $options = get_option('mxchat_options', []);
1272 $name_field_enabled = isset($options['enable_name_field']) &&
1273 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1274
1275 $email_option_key = "mxchat_email_{$session_id}";
1276 $stored_email = get_option($email_option_key, '');
1277
1278 // Check for stored name
1279 $name_option_key = "mxchat_name_{$session_id}";
1280 $stored_name = get_option($name_option_key, '');
1281
1282 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1283 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1284
1285 // Check if we have email and name (if name is required)
1286 $has_required_info = !empty($stored_email);
1287
1288 if ($name_field_enabled) {
1289 $has_required_info = $has_required_info && !empty($stored_name);
1290 }
1291
1292 if ($has_required_info) {
1293 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1294
1295 $response_data = ['email' => $stored_email];
1296 if (!empty($stored_name)) {
1297 $response_data['name'] = $stored_name;
1298 }
1299
1300 wp_send_json_success($response_data);
1301 } else {
1302 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1303 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1304 }
1305 }
1306
1307 /**
1308 * Send error response in appropriate format based on streaming mode
1309 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1310 *
1311 * @param string $error_message The error message to display
1312 * @param string $error_code Optional error code for debugging
1313 */
1314 private function send_error_response($error_message, $error_code = 'api_error') {
1315 if ($this->is_streaming) {
1316 echo "data: " . json_encode([
1317 'error' => true,
1318 'error_message' => $error_message,
1319 'error_code' => $error_code,
1320 'text' => $error_message,
1321 'message' => $error_message
1322 ]) . "\n\n";
1323 echo "data: [DONE]\n\n";
1324 flush();
1325 } else {
1326 wp_send_json_error([
1327 'error_message' => $error_message,
1328 'error_code' => $error_code
1329 ]);
1330 }
1331 wp_die();
1332 }
1333
1334 public function mxchat_handle_chat_request() {
1335 global $wpdb;
1336
1337 // Debug: Log incoming bot_id
1338 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1339 //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1340 //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1341
1342 // Get bot-specific options
1343 $bot_options = $this->get_bot_options($bot_id);
1344 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1345
1346 // Check if this is a streaming request
1347 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1348 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1349 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1350 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1351
1352 // ADDED: Store streaming state in class property for use in private methods
1353 $this->is_streaming = $is_streaming;
1354
1355 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1356 // This allows actions/forms to return JSON responses without header conflicts
1357
1358 // Check if MX Chat Moderation is active
1359 if (class_exists('MX_Chat_Moderation')) {
1360 // Get user email and IP
1361 $user_email = '';
1362 $user_ip = $_SERVER['REMOTE_ADDR'];
1363
1364 // If user is logged in, get their email
1365 if (is_user_logged_in()) {
1366 $current_user = wp_get_current_user();
1367 $user_email = $current_user->user_email;
1368 }
1369
1370 // Create ban handler instance
1371 $ban_handler = new MX_Chat_Ban_Handler();
1372
1373 // Check if user is banned by IP
1374 if ($ban_handler->check_ban($user_ip, 'ip')) {
1375 wp_send_json([
1376 'success' => false,
1377 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1378 'status' => 'banned'
1379 ]);
1380 wp_die();
1381 }
1382
1383 // If user is logged in, also check email
1384 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1385 wp_send_json([
1386 'success' => false,
1387 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1388 'status' => 'banned'
1389 ]);
1390 wp_die();
1391 }
1392 }
1393
1394 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1395 $this->productCardHtml = '';
1396
1397 // Get the actual WordPress user ID if logged in
1398 $is_logged_in = is_user_logged_in();
1399 if ($is_logged_in) {
1400 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1401 } else {
1402 // For logged-out users, use your existing identifier method
1403 $user_id = $this->mxchat_get_user_identifier();
1404 }
1405
1406 // Get and sanitize the user identifier
1407 $user_id = sanitize_key($user_id);
1408
1409 // Check rate limit using new settings structure
1410 $rate_limit_result = $this->check_rate_limit();
1411
1412 if ($rate_limit_result !== true) {
1413 wp_send_json([
1414 'success' => false,
1415 'message' => $rate_limit_result['message'],
1416 'status' => 'rate_limit_exceeded'
1417 ]);
1418 wp_die();
1419 }
1420
1421 // Rest of your existing code...
1422 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1423
1424 // Treat the literal strings 'null' / 'undefined' as missing too. Browser edge cases
1425 // (Safari ITP, private mode, cross-origin iframes with partitioned storage) can cause
1426 // the frontend FormData.append() to stringify a null session_id into the literal
1427 // "null", which would otherwise pass empty() and pollute the transcripts table with
1428 // ghost sessions that group every visitor's first message under one row.
1429 if ($session_id === 'null' || $session_id === 'undefined') {
1430 $session_id = '';
1431 }
1432
1433 if (empty($session_id)) {
1434 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1435 wp_die();
1436 }
1437
1438 // Update session owner if it changed (e.g. IP changed due to network switch)
1439 // The session ID itself is the authentication — if the client has it, they own it
1440 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1441 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1442
1443 if (!$session_owner || $session_owner !== $current_user_identifier) {
1444 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1445 }
1446
1447 // Validate and sanitize the incoming message
1448 if (empty($_POST['message'])) {
1449 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1450 wp_die();
1451 }
1452
1453
1454 // Track originating page for first message in session
1455 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1456
1457 // Check if originating page columns exist
1458 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1459
1460 if ($columns_exist) {
1461 // Check if this session already has messages
1462 $message_count = $wpdb->get_var($wpdb->prepare(
1463 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1464 $session_id
1465 ));
1466
1467 // If this is the first message in the session
1468 if ($message_count == 0) {
1469 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1470 $originating_url = '';
1471 $originating_title = '';
1472
1473 // Try to get from POST data first (sent by JavaScript)
1474 if (isset($_POST['current_page_url'])) {
1475 $originating_url = esc_url_raw($_POST['current_page_url']);
1476 $originating_title = isset($_POST['current_page_title'])
1477 ? sanitize_text_field($_POST['current_page_title'])
1478 : '';
1479 }
1480 // Fallback to HTTP_REFERER if not provided by JavaScript
1481 else if (isset($_SERVER['HTTP_REFERER'])) {
1482 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1483 }
1484
1485 // Generate title if we have URL but no title
1486 if ($originating_url && empty($originating_title)) {
1487 $parsed_url = parse_url($originating_url);
1488 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1489
1490 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1491 $originating_title = 'Homepage';
1492 } else {
1493 // Clean up the path to make a readable title
1494 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1495 $originating_title = ucwords(trim($originating_title));
1496 }
1497 }
1498
1499 // Store for later use when saving the message
1500 $this->pending_originating_page = [
1501 'url' => $originating_url,
1502 'title' => $originating_title
1503 ];
1504 }
1505 }
1506
1507
1508
1509 // Get page context if provided
1510 $page_context = null;
1511 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1512 $page_context_raw = stripslashes($_POST['page_context']);
1513 $page_context = json_decode($page_context_raw, true);
1514
1515 // Validate page context structure
1516 if (is_array($page_context) &&
1517 isset($page_context['url']) &&
1518 isset($page_context['title']) &&
1519 isset($page_context['content'])) {
1520
1521 // Sanitize page context
1522 $page_context['url'] = esc_url_raw($page_context['url']);
1523 $page_context['title'] = sanitize_text_field($page_context['title']);
1524 $page_context['content'] = wp_kses_post($page_context['content']);
1525 } else {
1526 $page_context = null;
1527 }
1528 }
1529
1530 // Modify the message sanitization to preserve PHP tags in code blocks
1531 $allowed_tags = [
1532 'pre' => [],
1533 'code' => ['class' => true],
1534 'span' => ['class' => true],
1535 'div' => ['class' => true],
1536 ];
1537
1538 // First preserve code blocks
1539 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1540 return htmlspecialchars_decode($matches[0]);
1541 }, $_POST['message']);
1542
1543 // Then apply sanitization
1544 $message = wp_kses($message, $allowed_tags);
1545
1546 // Preserve code blocks from markdown conversion
1547 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1548 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1549
1550 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1551 // Always initialize testing data for admins (no toggle needed)
1552 $testing_data = null;
1553 if (current_user_can('administrator')) {
1554 // For vision messages, use the original user message for the query display
1555 $query_for_testing = $message;
1556 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1557 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1558 }
1559
1560 $testing_data = [
1561 'query' => $query_for_testing,
1562 'timestamp' => time(),
1563 'top_matches' => [],
1564 'action_matches' => [], // Initialize action matches array
1565 'page_context' => $page_context, // Include page context in testing data
1566 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1567 'bot_id' => $bot_id // Include bot ID in testing data
1568 ];
1569
1570 // Get similarity threshold from bot options or default options
1571 $similarity_threshold = isset($current_options['similarity_threshold'])
1572 ? ((int) $current_options['similarity_threshold']) / 100
1573 : 0.35;
1574
1575 $testing_data['similarity_threshold'] = $similarity_threshold;
1576
1577 // Determine knowledge base type using bot-specific config
1578 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1579 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1580 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1581 }
1582 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1583
1584 // Add debug before and after:
1585 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1586 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1587 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1588
1589
1590 // If the pre-processing returned a result (not the original message), use it directly
1591 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1592 // Save the AI response
1593 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1594
1595 // Save HTML content if provided
1596 if (!empty($pre_processed_result['html'])) {
1597 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1598 }
1599
1600 // Add testing data if admin
1601 $response_data = [
1602 'text' => $pre_processed_result['text'],
1603 'html' => $pre_processed_result['html'] ?? '',
1604 'session_id' => $session_id
1605 ];
1606
1607 if ($testing_data !== null) {
1608 $response_data['testing_data'] = $testing_data;
1609 }
1610
1611 wp_send_json($response_data);
1612 wp_die();
1613 }
1614
1615 // Save the user's message - handle vision processed messages differently
1616 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1617 // For vision messages, save the original user message with image indicator
1618 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1619 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1620 $image_count = intval($_POST['vision_images_count']);
1621 $original_message .= " [{$image_count} image(s)]";
1622 }
1623 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1624 } else {
1625 // Regular message - save as normal
1626 $this->mxchat_save_chat_message($session_id, 'user', $message);
1627 }
1628
1629
1630 if (is_email($message)) {
1631 // Add the email to Loops
1632 $this->add_email_to_loops($message);
1633
1634 // Get the user's success message instruction using current_options
1635 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1636
1637 // Set instruction for AI using the user's success message
1638 $this->current_action_instruction = $user_success_message;
1639
1640 // Clear the email capture transient since we got the email
1641 delete_transient('mxchat_email_capture_' . $user_id);
1642 }
1643
1644 // Check if we're in an email capture flow but user hasn't provided email yet
1645 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1646 // Check if the message contains an email (not the whole message being an email)
1647 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1648 $extracted_email = $matches[0];
1649
1650 // Add the extracted email to Loops
1651 $this->add_email_to_loops($extracted_email);
1652
1653 // Get the user's success message instruction using current_options
1654 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1655
1656 // Set instruction for AI using the user's success message
1657 $this->current_action_instruction = $user_success_message;
1658
1659 // Clear the email capture transient since we got the email
1660 delete_transient('mxchat_email_capture_' . $user_id);
1661 }
1662 // If no email found but we're in capture mode, remind them
1663 else {
1664 // Get the original instruction to remind them using current_options
1665 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1666 $this->current_action_instruction = $original_instruction;
1667 }
1668 }
1669
1670 $intent_info = '';
1671
1672 // Check chat mode
1673 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1674
1675 // Handle agent mode
1676 // Handle agent mode
1677 if ($chat_mode === 'agent') {
1678 // First, check for switch intent before doing anything else
1679 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1680
1681 // Capture action analysis for testing panel after intent check
1682 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1683 $testing_data['action_matches'] = $this->last_action_analysis;
1684 }
1685
1686 // Around line 506, in the agent mode handling section:
1687 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1688 // Update chat mode first
1689 update_option("mxchat_mode_{$session_id}", 'ai');
1690
1691 // Clear any existing PDF context to start fresh
1692 $this->clear_pdf_transients($session_id);
1693
1694 // Prepare clean switch response with explicit chat_mode
1695 $response_data = [
1696 'text' => $this->fallbackResponse['text'],
1697 'html' => $this->fallbackResponse['html'] ?? '',
1698 'session_id' => $session_id,
1699 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1700 ];
1701
1702 if ($testing_data !== null) {
1703 $response_data['testing_data'] = $testing_data;
1704 }
1705
1706 // Save the mode switch message
1707 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1708 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1709
1710 // Send response and exit
1711 wp_send_json($response_data);
1712 wp_die();
1713 } elseif (!$intent_matched) {
1714 // No intent matched, handle live agent message
1715 try {
1716 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1717
1718 $agent_response = [
1719 'status' => 'waiting_for_agent',
1720 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1721 ];
1722
1723 if ($testing_data !== null) {
1724 $agent_response['testing_data'] = $testing_data;
1725 }
1726
1727 wp_send_json_success($agent_response);
1728 } catch (\Exception $e) {
1729 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1730 }
1731 wp_die();
1732 }
1733 }
1734
1735 // Step 1: Check for new PDF URL in the message
1736 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1737 $new_pdf_url = $matches[0];
1738
1739 // Check if this is likely a PDF-related request
1740 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1741 $is_pdf_request = false;
1742
1743 foreach ($pdf_keywords as $keyword) {
1744 if (stripos($message, $keyword) !== false) {
1745 $is_pdf_request = true;
1746 break;
1747 }
1748 }
1749
1750 // If it looks like a PDF request or we're waiting for a PDF URL
1751 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1752 // Validate HTTPS
1753 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1754 // Extract filename from URL
1755 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1756
1757 // Clear previous PDF transients
1758 $this->clear_pdf_transients($session_id);
1759
1760 // Process new PDF using current_options
1761 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1762 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1763
1764 if ($embeddings === 'too_many_pages') {
1765 $error_text = sprintf(
1766 $current_options['pdf_intent_error_text'] ??
1767 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1768 $max_pages
1769 );
1770 $this->fallbackResponse['text'] = $error_text;
1771 } elseif ($embeddings) {
1772 // Store new PDF information
1773 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1774
1775 // If the filename is generic, create a more descriptive one
1776 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1777 strpos($pdf_filename, '.php') !== false) {
1778 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1779 }
1780
1781 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1782 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1783 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1784 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1785
1786 $success_text = $current_options['pdf_intent_success_text'] ??
1787 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1788
1789 $pdf_response = [
1790 'success' => true,
1791 'message' => $success_text,
1792 'data' => [
1793 'filename' => $pdf_filename
1794 ]
1795 ];
1796
1797 if ($testing_data !== null) {
1798 $pdf_response['testing_data'] = $testing_data;
1799 }
1800
1801 wp_send_json($pdf_response);
1802 wp_die();
1803 } else {
1804 $error_text = $current_options['pdf_intent_error_text'] ??
1805 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1806 $this->fallbackResponse['text'] = $error_text;
1807 }
1808
1809 $pdf_error_response = [
1810 'success' => false,
1811 'message' => $this->fallbackResponse['text']
1812 ];
1813
1814 if ($testing_data !== null) {
1815 $pdf_error_response['testing_data'] = $testing_data;
1816 }
1817
1818 wp_send_json($pdf_error_response);
1819 wp_die();
1820 }
1821 }
1822 }
1823
1824
1825 // Step 2: Detect intent and handle intent-based responses
1826 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1827
1828 // Capture action analysis for testing panel after intent check
1829 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1830 $testing_data['action_matches'] = $this->last_action_analysis;
1831 }
1832
1833 // Step 3: Handle the intent result appropriately
1834 if ($intent_result !== false) {
1835 // Intent was matched - ALWAYS send as JSON response, never streaming
1836
1837 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1838 // Intent returned a direct response array
1839 $response_data = [
1840 'text' => $intent_result['text'] ?? '',
1841 'html' => $intent_result['html'] ?? '',
1842 'session_id' => $session_id
1843 ];
1844
1845 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1846 if (isset($intent_result['chat_mode'])) {
1847 $response_data['chat_mode'] = $intent_result['chat_mode'];
1848 }
1849
1850 if ($testing_data !== null) {
1851 $response_data['testing_data'] = $testing_data;
1852 }
1853
1854 wp_send_json($response_data);
1855 wp_die();
1856 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1857 // Intent returned true and set fallbackResponse
1858
1859 // SAVE TO TRANSCRIPT
1860 if (!empty($this->fallbackResponse['text'])) {
1861 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1862 }
1863 // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
1864 if (!empty($this->fallbackResponse['html'])) {
1865 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1866 }
1867
1868 $response_data = [
1869 'text' => $this->fallbackResponse['text'] ?? '',
1870 'html' => $this->fallbackResponse['html'] ?? '',
1871 'session_id' => $session_id
1872 ];
1873
1874 if (isset($this->fallbackResponse['chat_mode'])) {
1875 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1876 }
1877
1878 if ($testing_data !== null) {
1879 $response_data['testing_data'] = $testing_data;
1880 }
1881
1882 wp_send_json($response_data);
1883 wp_die();
1884 }
1885 }
1886
1887 // If we get here, no intent matched OR the intent didn't provide a usable response
1888
1889 // Step 4: Generate AI response
1890 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1891 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1892 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1893 $this->mxchat_increment_chat_count();
1894
1895 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1896 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1897 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1898
1899 // Check if the embedding generation returned an error
1900 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1901 $error_message = $user_message_embedding['error'];
1902 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1903
1904 // FIXED: Send error in appropriate format based on streaming mode
1905 if ($is_streaming) {
1906 echo "data: " . json_encode([
1907 'error' => true,
1908 'error_message' => $error_message,
1909 'error_code' => $error_code,
1910 'text' => $error_message,
1911 'message' => $error_message
1912 ]) . "\n\n";
1913 echo "data: [DONE]\n\n";
1914 flush();
1915 } else {
1916 wp_send_json_error([
1917 'error_message' => $error_message,
1918 'error_code' => $error_code
1919 ]);
1920 }
1921 wp_die();
1922 }
1923
1924 // Check if the embedding is valid
1925 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1926 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1927
1928 // FIXED: Send error in appropriate format based on streaming mode
1929 if ($is_streaming) {
1930 echo "data: " . json_encode([
1931 'error' => true,
1932 'error_message' => $error_message,
1933 'error_code' => 'invalid_embedding',
1934 'text' => $error_message,
1935 'message' => $error_message
1936 ]) . "\n\n";
1937 echo "data: [DONE]\n\n";
1938 flush();
1939 } else {
1940 wp_send_json_error([
1941 'error_message' => $error_message,
1942 'error_code' => 'invalid_embedding'
1943 ]);
1944 }
1945 wp_die();
1946 }
1947
1948 // Build context with both knowledge base and PDF content if available
1949 $context_content = "User asked: '{$message}'\n\n";
1950
1951 // Add action instruction if present (add this right after the above line)
1952 if (!empty($this->current_action_instruction)) {
1953 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1954 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1955 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1956 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1957
1958 // Clear the instruction after using it
1959 $this->current_action_instruction = null;
1960 }
1961
1962
1963 // Add page context if available and contextual awareness is enabled using current_options
1964 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1965 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1966 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1967 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1968 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1969 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1970 }
1971
1972 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1973 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1974
1975 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1976 // Use fresh options to ensure we get the latest setting value
1977 $fresh_options = get_option('mxchat_options', []);
1978 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1979
1980 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1981 if ($citation_links_enabled && !empty($system_instructions)) {
1982 preg_match_all(
1983 '#\bhttps?://[^\s<>"\']+#i',
1984 $system_instructions,
1985 $system_instruction_urls
1986 );
1987
1988 if (!empty($system_instruction_urls[0])) {
1989 // Merge with existing valid URLs
1990 $this->current_valid_urls = array_merge(
1991 $this->current_valid_urls,
1992 $system_instruction_urls[0]
1993 );
1994 // Remove duplicates
1995 $this->current_valid_urls = array_unique($this->current_valid_urls);
1996
1997 //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1998 }
1999 }
2000
2001 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2002 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2003 // Update testing data with the REAL similarity analysis
2004 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2005 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2006 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2007 $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2008 $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2009 }
2010 // ===== END SIMILARITY DATA CAPTURE =====
2011
2012 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2013 if ($testing_data !== null && !empty($this->current_valid_urls)) {
2014 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2015 //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2016 }
2017
2018 if (!empty($relevant_content)) {
2019 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
2020 } else {
2021 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2022 }
2023
2024 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2025 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2026 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2027 $context_content .= "You may ONLY use these exact URLs in your response:\n";
2028 foreach ($this->current_valid_urls as $url) {
2029 $context_content .= "- " . $url . "\n";
2030 }
2031 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2032 $context_content .= "===== END APPROVED URLS =====\n\n";
2033 }
2034
2035 // Check for and include PDF content
2036 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2037 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2038 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2039 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2040 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2041 if (!empty($relevant_pdf_pages)) {
2042 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2043 foreach ($relevant_pdf_pages as $page_data) {
2044 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2045 }
2046 $context_content .= "\n";
2047 }
2048 }
2049
2050 // Check for and include Word content
2051 $word_url = get_transient('mxchat_word_url_' . $session_id);
2052 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2053 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2054 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2055 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2056 if (!empty($relevant_word_chunks)) {
2057 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2058 foreach ($relevant_word_chunks as $chunk_data) {
2059 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2060 }
2061 $context_content .= "\n";
2062 }
2063 }
2064
2065 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2066
2067 // Extract model from current options for bot-specific model support
2068 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
2069
2070 $response = $this->mxchat_generate_response(
2071 $context_content,
2072 $current_options['api_key'] ?? $this->options['api_key'],
2073 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2074 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2075 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2076 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2077 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2078 $conversation_history,
2079 $is_streaming,
2080 $session_id,
2081 $testing_data,
2082 $selected_model
2083 );
2084
2085 // Handle streaming vs non-streaming responses
2086 if ($is_streaming) {
2087 // Check if streaming actually happened or if it fell back to regular response
2088 if ($response === true) {
2089 wp_die();
2090 }
2091 // If we get here, streaming fell back to regular response, continue
2092 // But if there's an error, we need to send it as SSE format since headers are already set
2093 if (is_array($response) && isset($response['error'])) {
2094 $error_message = $response['error'];
2095 $error_code = $response['error_code'] ?? 'api_error';
2096 // Send error in SSE format that the client JS can handle
2097 echo "data: " . json_encode([
2098 'error' => true,
2099 'error_message' => $error_message,
2100 'error_code' => $error_code,
2101 'text' => $error_message, // Also include as text for fallback handling
2102 'message' => $error_message
2103 ]) . "\n\n";
2104 echo "data: [DONE]\n\n";
2105 flush();
2106 wp_die();
2107 }
2108 }
2109
2110 // Check if the response is an error array (non-streaming mode)
2111 if (is_array($response) && isset($response['error'])) {
2112 wp_send_json_error([
2113 'error_message' => $response['error'],
2114 'error_code' => $response['error_code'] ?? 'api_error'
2115 ]);
2116 wp_die();
2117 }
2118
2119 // DEBUG: Check what we have
2120 //error_log("=== BEFORE URL VALIDATION ===");
2121 //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2122 //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2123 //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2124
2125 // If we get here, the response is valid text - now validate URLs
2126 if (!empty($this->current_valid_urls)) {
2127 //error_log("CALLING validate_and_clean_urls");
2128 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
2129 } else {
2130 //error_log("SKIPPING validation - current_valid_urls is empty");
2131 }
2132 // ===== END URL VALIDATION =====
2133
2134 // Prepare RAG context data for storage (only include documents used for context)
2135 $rag_context_for_storage = null;
2136 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2137 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2138
2139 if ($has_rag_data || $has_action_data) {
2140 $rag_context_for_storage = [];
2141
2142 // Add RAG/source data if available
2143 if ($has_rag_data) {
2144 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2145 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2146 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2147 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2148 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2149 $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2150 $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2151 }
2152
2153 // Add action analysis data if available
2154 if ($has_action_data) {
2155 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2156 }
2157 }
2158
2159 // Save the cleaned response with RAG context
2160 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2161
2162 // Step 5: Save additional content if available
2163 if (!empty($this->productCardHtml)) {
2164 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2165 }
2166
2167 if (!empty($this->fallbackResponse['html'])) {
2168 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2169 }
2170
2171 // Step 6: Return the response
2172 // DEBUG: Check if newlines exist in the response
2173 //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2174 //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2175 //error_log("Response first 500 chars: " . substr($response, 0, 500));
2176
2177 $response_data = [
2178 'text' => $response,
2179 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
2180 'session_id' => $session_id
2181 ];
2182
2183 // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2184 if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2185 $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2186 }
2187
2188 // Also pass it as a top-level field so JS can show a better error message to admins
2189 if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2190 $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2191 }
2192
2193 // Always add testing data for admins (no toggle needed)
2194 if ($testing_data !== null) {
2195 $response_data['testing_data'] = $testing_data;
2196 }
2197
2198 wp_send_json($response_data);
2199 wp_die();
2200 }
2201
2202 /**
2203 * Get bot-specific options for multi-bot functionality
2204 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2205 */
2206 // Also debug the bot options retrieval
2207 private function get_bot_options($bot_id = 'default') {
2208 //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2209
2210 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2211 //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2212 return array();
2213 }
2214
2215 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2216
2217 if (!empty($bot_options)) {
2218 //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2219 if (isset($bot_options['similarity_threshold'])) {
2220 //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2221 }
2222 }
2223
2224 return is_array($bot_options) ? $bot_options : array();
2225 }
2226
2227 /**
2228 * Get bot-specific Pinecone configuration
2229 * Used in the knowledge retrieval functions
2230 */
2231 // Also add debugging to your get_bot_pinecone_config function
2232 private function get_bot_pinecone_config($bot_id = 'default') {
2233 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
2234
2235 // If default bot or multi-bot add-on not active, use default Pinecone config
2236 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2237 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2238 $addon_options = get_option('mxchat_pinecone_addon_options', array());
2239 $config = array(
2240 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2241 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2242 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2243 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2244 );
2245 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2246 return $config;
2247 }
2248
2249 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2250
2251 // Hook for multi-bot add-on to provide bot-specific Pinecone config
2252 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2253
2254 if (!empty($bot_pinecone_config)) {
2255 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2256 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2257 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2258 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2259 } else {
2260 //error_log("MXCHAT DEBUG: Filter returned empty config!");
2261 }
2262
2263 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
2264 }
2265
2266
2267 // Updated function to check intents and invoke the callback function
2268 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2269 global $wpdb;
2270 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2271
2272 // Get the current bot_id
2273 $current_bot_id = $this->get_current_bot_id($session_id);
2274
2275 // Generate the user embedding
2276 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2277
2278 // Check if embedding generation returned an error
2279 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2280 $error_message = $user_embedding['error'];
2281 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2282
2283 // FIXED: Send error in appropriate format based on streaming mode
2284 if ($this->is_streaming) {
2285 echo "data: " . json_encode([
2286 'error' => true,
2287 'error_message' => $error_message,
2288 'error_code' => $error_code,
2289 'text' => $error_message,
2290 'message' => $error_message
2291 ]) . "\n\n";
2292 echo "data: [DONE]\n\n";
2293 flush();
2294 } else {
2295 wp_send_json_error([
2296 'error_message' => $error_message,
2297 'error_code' => $error_code
2298 ]);
2299 }
2300 wp_die();
2301 }
2302
2303 // Check if embedding is valid
2304 if (!is_array($user_embedding) || empty($user_embedding)) {
2305 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2306
2307 // FIXED: Send error in appropriate format based on streaming mode
2308 if ($this->is_streaming) {
2309 echo "data: " . json_encode([
2310 'error' => true,
2311 'error_message' => $error_message,
2312 'error_code' => 'invalid_embedding',
2313 'text' => $error_message,
2314 'message' => $error_message
2315 ]) . "\n\n";
2316 echo "data: [DONE]\n\n";
2317 flush();
2318 } else {
2319 wp_send_json_error([
2320 'error_message' => $error_message,
2321 'error_code' => 'invalid_embedding'
2322 ]);
2323 }
2324 wp_die();
2325 }
2326
2327 // Fetch intents from the database
2328 $table_name = $wpdb->prefix . 'mxchat_intents';
2329 if ($chat_mode === 'agent') {
2330 $query = $wpdb->prepare(
2331 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2332 'mxchat_handle_switch_to_chatbot_intent'
2333 );
2334 $intents = $wpdb->get_results($query);
2335 } else {
2336 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2337 }
2338
2339 if (empty($intents)) {
2340 return false;
2341 }
2342
2343 // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2344 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2345 $phrases_by_intent = [];
2346 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2347 $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2348 foreach ($all_phrases as $p) {
2349 $phrases_by_intent[$p->intent_id][] = $p;
2350 }
2351 }
2352
2353 $highest_similarity = -INF;
2354 $matched_intent = null;
2355
2356 // Array to store action analysis for testing panel
2357 $action_analysis = [];
2358
2359 foreach ($intents as $intent) {
2360 // Additional check for enabled state
2361 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2362 if (!$is_enabled) {
2363 continue;
2364 }
2365
2366 // Check if this action is enabled for the current bot
2367 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2368 continue;
2369 }
2370
2371 $best_similarity = -INF;
2372 $matched_phrase_text = '';
2373
2374 // Check legacy embedding vector (existing behavior)
2375 $intent_embedding_serialized = $intent->embedding_vector;
2376 $intent_embedding = $intent_embedding_serialized
2377 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2378 : null;
2379
2380 if (is_array($intent_embedding) && !empty($intent_embedding)) {
2381 $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2382 if ($legacy_similarity > $best_similarity) {
2383 $best_similarity = $legacy_similarity;
2384 $matched_phrase_text = 'legacy';
2385 }
2386 }
2387
2388 // Check individual phrase vectors
2389 if (isset($phrases_by_intent[$intent->id])) {
2390 foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2391 $phrase_embedding = $phrase_row->embedding_vector
2392 ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2393 : null;
2394 if (!is_array($phrase_embedding)) {
2395 continue;
2396 }
2397 $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2398 if ($phrase_similarity > $best_similarity) {
2399 $best_similarity = $phrase_similarity;
2400 $matched_phrase_text = $phrase_row->phrase;
2401 }
2402 }
2403 }
2404
2405 // Skip if no valid embedding was found at all
2406 if ($best_similarity === -INF) {
2407 continue;
2408 }
2409
2410 $similarity = $best_similarity;
2411 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2412
2413 // Store action analysis data for testing panel
2414 $action_analysis[] = [
2415 'intent_label' => $intent->intent_label,
2416 'callback_function' => $intent->callback_function,
2417 'similarity' => round($similarity, 4),
2418 'similarity_percentage' => round($similarity * 100, 2),
2419 'threshold' => $intent_threshold,
2420 'threshold_percentage' => round($intent_threshold * 100, 2),
2421 'above_threshold' => $similarity >= $intent_threshold,
2422 'matched_phrase' => $matched_phrase_text,
2423 'triggered' => false // Will be updated below if this intent is triggered
2424 ];
2425
2426 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2427 $highest_similarity = $similarity;
2428 $matched_intent = $intent;
2429 }
2430 }
2431
2432 // Mark the triggered action if any
2433 if ($matched_intent) {
2434 foreach ($action_analysis as &$action) {
2435 if ($action['intent_label'] === $matched_intent->intent_label) {
2436 $action['triggered'] = true;
2437 break;
2438 }
2439 }
2440 }
2441
2442 // Sort actions by similarity (highest first) and store for testing panel
2443 usort($action_analysis, function($a, $b) {
2444 return $b['similarity'] <=> $a['similarity'];
2445 });
2446
2447 // Store action analysis for testing panel capture
2448 $this->last_action_analysis = $action_analysis;
2449
2450 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2451 if ($matched_intent) {
2452 // If the callback is a method on this instance (core callback), call it directly
2453 if (method_exists($this, $matched_intent->callback_function)) {
2454 $callback_result = call_user_func(
2455 [$this, $matched_intent->callback_function],
2456 $message,
2457 $user_id,
2458 $session_id,
2459 $matched_intent,
2460 $user_context ?? null
2461 );
2462 } else {
2463 // Otherwise, use apply_filters for add-on callbacks
2464 $callback_result = apply_filters(
2465 $matched_intent->callback_function,
2466 false,
2467 $message,
2468 $user_id,
2469 $session_id,
2470 $matched_intent
2471 );
2472 }
2473
2474 // Handle the callback result properly
2475 if ($callback_result !== false) {
2476 // If callback returned an array with chat_mode, use it directly
2477 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2478 $this->fallbackResponse = $callback_result;
2479 return $callback_result; // Return the full array
2480 } else {
2481 $this->fallbackResponse = $callback_result;
2482 return true;
2483 }
2484 }
2485 }
2486
2487 return false;
2488 }
2489
2490 /**
2491 * Check if an action is enabled for a specific bot
2492 */
2493 private function is_action_enabled_for_bot($intent, $bot_id) {
2494 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2495 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2496 return true;
2497 }
2498
2499 $enabled_bots = json_decode($intent->enabled_bots, true);
2500
2501 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2502 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2503 return true;
2504 }
2505
2506 // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2507 // default-bot actions are testable from the admin panel
2508 if ($bot_id === 'testing') {
2509 $bot_id = 'default';
2510 }
2511
2512 // Check if the current bot is in the enabled bots list
2513 return in_array($bot_id, $enabled_bots);
2514 }
2515
2516 // Helper function to clear PDF and Word document related transients
2517 private function clear_pdf_transients($session_id) {
2518 // PDF transients
2519 delete_transient('mxchat_pdf_url_' . $session_id);
2520 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2521 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2522 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2523
2524 // Word document transients
2525 delete_transient('mxchat_word_url_' . $session_id);
2526 delete_transient('mxchat_word_filename_' . $session_id);
2527 delete_transient('mxchat_word_embeddings_' . $session_id);
2528 delete_transient('mxchat_include_word_in_context_' . $session_id);
2529 delete_transient('mxchat_waiting_for_word_' . $session_id);
2530 }
2531
2532
2533
2534 //verified good
2535 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2536 // Get the user's original instruction/message
2537 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2538
2539 // Set instruction for AI - just pass along what the user wanted to say
2540 $this->current_action_instruction = $user_instruction;
2541
2542 // Set the transient to track email capture flow
2543 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2544
2545 // Return false to let the AI generate the response
2546 return false;
2547 }
2548
2549 public function mxchat_generate_image($message, $user_id, $session_id) {
2550 //error_log("Starting image generation for message: " . $message);
2551
2552 // Prepare a prompt for OpenAI image generation
2553 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2554
2555 // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2556 // through the configured Custom (OpenAI-compatible) /images/generations route.
2557 if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2558 $image_response = $this->mxchat_generate_custom_image($prompt);
2559 } else {
2560 // Use the existing OpenAI API key
2561 $openai_api_key = sanitize_text_field($this->options['api_key']);
2562 // Call OpenAI GPT Image to generate an image
2563 $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2564 }
2565
2566 // Check if the response contains an image URL
2567 if (isset($image_response['imageUrl'])) {
2568 $image_url = esc_url_raw($image_response['imageUrl']);
2569
2570 // Construct the HTML with a CSS class instead of inline styles
2571 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2572 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2573
2574 // Save the bot message with both text and HTML
2575 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2576 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2577
2578 // Set the fallback response for the chat handler
2579 $this->fallbackResponse = [
2580 'text' => $response_text,
2581 'html' => $response_html,
2582 'images' => [$image_url]
2583 ];
2584
2585 // For debugging/verification - Use json_encode to verify what's being set
2586 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2587
2588 // Return the response directly instead of relying on the property
2589 return $this->fallbackResponse;
2590 } else {
2591 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2592
2593 // Save the error message
2594 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2595
2596 // Set the fallback response for the chat handler
2597 $this->fallbackResponse = [
2598 'text' => $response_text,
2599 'html' => '',
2600 'images' => []
2601 ];
2602
2603 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2604 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2605
2606 // Return the response directly instead of relying on the property
2607 return $this->fallbackResponse;
2608 }
2609 }
2610
2611 public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2612 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2613
2614 $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2615 if (empty($gemini_api_key)) {
2616 $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2617 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2618 return ['text' => $response_text, 'html' => '', 'images' => []];
2619 }
2620
2621 $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2622
2623 if (isset($image_response['imageUrl'])) {
2624 $image_url = esc_url_raw($image_response['imageUrl']);
2625
2626 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2627 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2628
2629 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2630 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2631
2632 $this->fallbackResponse = [
2633 'text' => $response_text,
2634 'html' => $response_html,
2635 'images' => [$image_url]
2636 ];
2637
2638 return $this->fallbackResponse;
2639 } else {
2640 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2641
2642 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2643
2644 $this->fallbackResponse = [
2645 'text' => $response_text,
2646 'html' => '',
2647 'images' => []
2648 ];
2649
2650 return $this->fallbackResponse;
2651 }
2652 }
2653
2654 private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
2655 $extension = ($mime_type === 'image/jpeg') ? 'jpg' : 'png';
2656 $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
2657 $decoded = base64_decode($base64_data);
2658
2659 if ($decoded === false) {
2660 return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
2661 }
2662
2663 $upload = wp_upload_bits($filename, null, $decoded);
2664
2665 if (!empty($upload['error'])) {
2666 return new \WP_Error('upload_failed', $upload['error']);
2667 }
2668
2669 $attach_id = wp_insert_attachment([
2670 'post_mime_type' => $mime_type,
2671 'post_title' => $prefix,
2672 'post_content' => '',
2673 'post_status' => 'inherit',
2674 ], $upload['file']);
2675
2676 if (is_wp_error($attach_id)) {
2677 return $attach_id;
2678 }
2679
2680 require_once ABSPATH . 'wp-admin/includes/image.php';
2681 $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
2682 wp_update_attachment_metadata($attach_id, $metadata);
2683
2684 return esc_url_raw(wp_get_attachment_url($attach_id));
2685 }
2686
2687 private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
2688 $api_url = 'https://api.openai.com/v1/images/generations';
2689 $body = json_encode([
2690 'prompt' => sanitize_text_field($prompt),
2691 'n' => 1,
2692 'size' => '1024x1024',
2693 'quality' => 'medium',
2694 'output_format' => 'png',
2695 'model' => sanitize_text_field($model),
2696 ]);
2697
2698 $args = [
2699 'body' => $body,
2700 'headers' => [
2701 'Content-Type' => 'application/json',
2702 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2703 ],
2704 'method' => 'POST',
2705 'timeout' => absint($timeout),
2706 ];
2707
2708 $response = wp_remote_post($api_url, $args);
2709
2710 if (is_wp_error($response)) {
2711 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2712 }
2713
2714 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2715
2716 $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
2717 if ($b64) {
2718 $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
2719 if (is_wp_error($saved_url)) {
2720 return ['error' => $saved_url->get_error_message()];
2721 }
2722 return ['imageUrl' => $saved_url];
2723 } else {
2724 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2725 }
2726 }
2727
2728 /**
2729 * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
2730 * Only called when the opt-in 'custom_provider_for_images' setting is on.
2731 */
2732 private function mxchat_generate_custom_image($prompt, $timeout = 90) {
2733 $cfg = $this->mxchat_resolve_custom_provider();
2734 if (empty($cfg['base_url'])) {
2735 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
2736 }
2737 $url = $cfg['base_url'] . '/images/generations';
2738 if (!empty($cfg['api_version'])) {
2739 $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
2740 }
2741 $body = wp_json_encode([
2742 'prompt' => sanitize_text_field($prompt),
2743 'n' => 1,
2744 'size' => '1024x1024',
2745 'model' => $cfg['model'],
2746 ]);
2747 $response = wp_remote_post($url, [
2748 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
2749 'body' => $body,
2750 'method' => 'POST',
2751 'timeout' => absint($timeout),
2752 ]);
2753 if (is_wp_error($response)) {
2754 return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
2755 }
2756 $resp = json_decode(wp_remote_retrieve_body($response), true);
2757 // Try b64 first (matches OpenAI shape), then url-based fallback.
2758 $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
2759 if ($b64) {
2760 $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
2761 if (is_wp_error($saved)) {
2762 return ['error' => $saved->get_error_message()];
2763 }
2764 return ['imageUrl' => $saved];
2765 }
2766 $remote_url = $resp['data'][0]['url'] ?? null;
2767 if ($remote_url) {
2768 return ['imageUrl' => esc_url_raw($remote_url)];
2769 }
2770 $err_msg = $resp['error']['message'] ?? esc_html__('Custom provider did not return an image.', 'mxchat');
2771 return ['error' => esc_html($err_msg)];
2772 }
2773
2774 private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
2775 $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
2776
2777 $body = json_encode([
2778 'instances' => [['prompt' => sanitize_text_field($prompt)]],
2779 'parameters' => [
2780 'sampleCount' => 1,
2781 'aspectRatio' => '1:1',
2782 ],
2783 ]);
2784
2785 $args = [
2786 'body' => $body,
2787 'headers' => [
2788 'Content-Type' => 'application/json',
2789 'x-goog-api-key' => sanitize_text_field($api_key),
2790 ],
2791 'method' => 'POST',
2792 'timeout' => absint($timeout),
2793 ];
2794
2795 $response = wp_remote_post($api_url, $args);
2796
2797 if (is_wp_error($response)) {
2798 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2799 }
2800
2801 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2802
2803 $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
2804 if ($b64) {
2805 $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
2806 $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
2807 if (is_wp_error($saved_url)) {
2808 return ['error' => $saved_url->get_error_message()];
2809 }
2810 return ['imageUrl' => $saved_url];
2811 } else {
2812 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2813 }
2814 }
2815
2816 /**
2817 * Handle web search requests.
2818 *
2819 * Sends the refined search query to the Brave Search API and uses the
2820 * results to generate a conversational response with the AI model.
2821 *
2822 * @since 1.0.0
2823 * @param string $message The user's search query.
2824 * @param string $user_id The user identifier.
2825 * @param string $session_id The current session ID.
2826 * @return array Response array containing text with embedded HTML links
2827 */
2828 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2829 // Step 1: Interpret and refine the search query
2830 $refined_search_query = $this->mxchat_interpret_search_query($message);
2831 if (empty($refined_search_query)) {
2832 return array(
2833 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2834 'html' => ''
2835 );
2836 }
2837
2838 // Retrieve and validate API settings
2839 $options = get_option('mxchat_options');
2840 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2841 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2842
2843 if (empty($api_key)) {
2844 return array(
2845 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2846 'html' => ''
2847 );
2848 }
2849
2850 // Build the API request URL
2851 $api_url = add_query_arg(
2852 array(
2853 'q' => rawurlencode($refined_search_query),
2854 'count' => $results_count,
2855 'text_decorations' => 'true',
2856 'rich_data' => 'true',
2857 ),
2858 'https://api.search.brave.com/res/v1/web/search'
2859 );
2860
2861 // Attempt to retrieve cached results first
2862 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2863 $results = get_transient($transient_key);
2864
2865 if (false === $results) {
2866 // SECURITY FIX: Changed to wp_safe_remote_get
2867 $response = wp_safe_remote_get(
2868 $api_url,
2869 array(
2870 'headers' => array(
2871 'Accept' => 'application/json',
2872 'Accept-Encoding' => 'gzip',
2873 'X-Subscription-Token'=> $api_key,
2874 ),
2875 'timeout' => 10,
2876 )
2877 );
2878
2879 if (is_wp_error($response)) {
2880 return array(
2881 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2882 'html' => ''
2883 );
2884 }
2885
2886 $results = json_decode(wp_remote_retrieve_body($response), true);
2887
2888 if (json_last_error() !== JSON_ERROR_NONE) {
2889 return array(
2890 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2891 'html' => ''
2892 );
2893 }
2894
2895 // Cache results for one hour
2896 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2897 }
2898
2899 // Process results
2900 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2901 // Create a more straightforward summary with HTML links
2902 $search_results_text = '';
2903
2904 // Add a simple intro
2905 $search_results_text .= sprintf(
2906 esc_html__("Here's what I found about '%s':", 'mxchat'),
2907 esc_html($refined_search_query)
2908 );
2909
2910 // Add the top results with HTML links
2911 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2912 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2913 $url = isset($result['url']) ? esc_url($result['url']) : '';
2914 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2915
2916 // Add a line break after the intro
2917 $search_results_text .= '<br><br>';
2918
2919 // Add title as a link
2920 $search_results_text .= sprintf(
2921 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2922 $url,
2923 $title
2924 );
2925
2926 // Add a condensed description
2927 $search_results_text .= sprintf("%s", $description);
2928 }
2929
2930 // Save to chat history
2931 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2932
2933 // Return the formatted text with embedded HTML links
2934 return array(
2935 'text' => $search_results_text,
2936 'html' => ''
2937 );
2938 } else {
2939 return array(
2940 'text' => sprintf(
2941 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2942 esc_html($refined_search_query)
2943 ),
2944 'html' => ''
2945 );
2946 }
2947 }
2948
2949 //very good
2950 /**
2951 * Handle image search requests from the chatbot
2952 *
2953 * @param string $message The user's search query
2954 * @param int $user_id The user's ID
2955 * @param string $session_id The chat session ID
2956 * @return array Response array with text and HTML content
2957 */
2958 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2959 // Step 1: Interpret the search query using the user's selected AI model
2960 $refined_search_query = $this->mxchat_interpret_search_query($message);
2961
2962 // If no query was interpreted, return a fallback message
2963 if (empty($refined_search_query)) {
2964 return array(
2965 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2966 'html' => "",
2967 );
2968 }
2969
2970 // Brave API URL
2971 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2972
2973 // Retrieve Brave API settings
2974 $options = get_option('mxchat_options');
2975 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2976
2977 if (empty($api_key)) {
2978 return array(
2979 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2980 'html' => "",
2981 );
2982 }
2983
2984 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2985 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2986
2987 // Append query parameters based on settings
2988 $api_url = add_query_arg([
2989 'q' => rawurlencode($refined_search_query),
2990 'count' => $image_count,
2991 'safesearch' => $safe_search,
2992 ], $api_url);
2993
2994 // Implement caching
2995 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2996 $body = get_transient($transient_key);
2997
2998 if (false === $body) {
2999 $args = [
3000 'headers' => [
3001 'Accept' => 'application/json',
3002 'Accept-Encoding' => 'gzip',
3003 'X-Subscription-Token' => $api_key,
3004 ],
3005 'timeout' => 10,
3006 ];
3007
3008 // SECURITY FIX: Changed to wp_safe_remote_get
3009 $response = wp_safe_remote_get($api_url, $args);
3010
3011 if (is_wp_error($response)) {
3012 return array(
3013 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
3014 'html' => "",
3015 );
3016 }
3017
3018 $body = json_decode(wp_remote_retrieve_body($response), true);
3019 set_transient($transient_key, $body, HOUR_IN_SECONDS);
3020 }
3021
3022 // Process the API response
3023 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
3024 $html_output = '<div class="mxchat-image-gallery">';
3025
3026 // Get the configured image count (1-6)
3027 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
3028 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
3029
3030 // Use only the requested number of images
3031 for ($i = 0; $i < $display_count; $i++) {
3032 $image = $body['results'][$i];
3033 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
3034 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
3035 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
3036
3037 if ($image_url && $thumbnail_url) {
3038 $html_output .= '<div class="mxchat-image-item">';
3039 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
3040 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
3041 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
3042 $html_output .= '</a></div>';
3043 }
3044 }
3045
3046 $html_output .= '</div>';
3047
3048 // Create response text
3049 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
3050
3051 // Save both response text and HTML to chat history
3052 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3053 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
3054
3055 // Return the combined response
3056 return array(
3057 'text' => $response_text,
3058 'html' => $html_output,
3059 );
3060 } else {
3061 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
3062
3063 // Save the error message to chat history
3064 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
3065
3066 return array(
3067 'text' => $response_text,
3068 'html' => "",
3069 );
3070 }
3071 }
3072
3073 /**
3074 * Interpret the search query using the user's selected AI model
3075 *
3076 * @param string $user_query The original query from the user
3077 * @return string The refined search query
3078 */
3079 public function mxchat_interpret_search_query($user_query) {
3080 $system_prompt = esc_html__("Interpret the user's request to provide only the essential keywords or phrases for image searching. Remove conversational language, politeness, or extra context. Return a concise search query that doesn't lose any of the original meaning.", 'mxchat');
3081
3082 // Get options and determine the selected model
3083 $options = $this->options ?? get_option('mxchat_options');
3084 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
3085
3086 // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3087 if ($selected_model === 'custom-provider') {
3088 return $this->interpret_query_with_custom($user_query, $system_prompt);
3089 }
3090
3091 // Extract model prefix to determine the provider
3092 $model_parts = explode('-', $selected_model);
3093 $provider = strtolower($model_parts[0]);
3094
3095 // Determine which API key to use based on the provider
3096 switch ($provider) {
3097 case 'gemini':
3098 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
3099 if (empty($api_key)) {
3100 return sanitize_text_field($user_query); // Default to original query if API key missing
3101 }
3102 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
3103
3104 case 'claude':
3105 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
3106 if (empty($api_key)) {
3107 return sanitize_text_field($user_query);
3108 }
3109 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
3110
3111 case 'grok':
3112 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
3113 if (empty($api_key)) {
3114 return sanitize_text_field($user_query);
3115 }
3116 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
3117
3118 case 'deepseek':
3119 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
3120 if (empty($api_key)) {
3121 return sanitize_text_field($user_query);
3122 }
3123 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
3124
3125 case 'gpt':
3126 default:
3127 // Default to OpenAI for custom models or unrecognized prefixes
3128 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
3129 if (empty($api_key)) {
3130 return sanitize_text_field($user_query);
3131 }
3132 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
3133 }
3134 }
3135
3136 /**
3137 * Interpret query against the configured Custom (OpenAI-compatible) provider.
3138 * Uses the same base URL + auth scheme as the chat dispatcher.
3139 */
3140 private function interpret_query_with_custom($user_query, $system_prompt) {
3141 $cfg = $this->mxchat_resolve_custom_provider();
3142 if (empty($cfg['base_url'])) {
3143 return sanitize_text_field($user_query);
3144 }
3145 $args = [
3146 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3147 'body' => wp_json_encode([
3148 'model' => $cfg['model'],
3149 'messages' => [
3150 ['role' => 'system', 'content' => $system_prompt],
3151 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3152 ],
3153 'temperature' => 0.2,
3154 'max_tokens' => 20,
3155 ]),
3156 'method' => 'POST',
3157 'timeout' => 15,
3158 ];
3159 $response = wp_remote_post($cfg['chat_url'], $args);
3160 if (is_wp_error($response)) {
3161 return sanitize_text_field($user_query);
3162 }
3163 $body = json_decode(wp_remote_retrieve_body($response), true);
3164 return isset($body['choices'][0]['message']['content'])
3165 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3166 : sanitize_text_field($user_query);
3167 }
3168
3169 /**
3170 * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3171 * into the assoc-array form wp_remote_post expects.
3172 */
3173 private function mxchat_custom_provider_assoc_headers($cfg) {
3174 $headers = ['Content-Type' => 'application/json'];
3175 if (!empty($cfg['api_key'])) {
3176 if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3177 $headers['api-key'] = $cfg['api_key'];
3178 } else {
3179 $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3180 }
3181 }
3182 return $headers;
3183 }
3184
3185 /**
3186 * Interpret query using OpenAI models
3187 */
3188 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
3189 $url = 'https://api.openai.com/v1/chat/completions';
3190 $args = [
3191 'headers' => [
3192 'Authorization' => 'Bearer ' . $api_key,
3193 'Content-Type' => 'application/json',
3194 ],
3195 'body' => wp_json_encode([
3196 'model' => $model,
3197 'messages' => [
3198 ['role' => 'system', 'content' => $system_prompt],
3199 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3200 ],
3201 'temperature' => 0.2,
3202 'max_tokens' => 20,
3203 ]),
3204 'method' => 'POST',
3205 'timeout' => 15,
3206 ];
3207
3208 $response = wp_remote_post($url, $args);
3209 if (is_wp_error($response)) {
3210 return sanitize_text_field($user_query);
3211 }
3212
3213 $body = json_decode(wp_remote_retrieve_body($response), true);
3214 return isset($body['choices'][0]['message']['content'])
3215 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3216 : sanitize_text_field($user_query);
3217 }
3218
3219 /**
3220 * Anthropic deprecated temperature/top_p/top_k starting with Opus 4.7 —
3221 * add new reasoning-Opus model ids here. (We don't send top_p/top_k in any
3222 * Claude body, so the list only needs to gate temperature stripping.)
3223 */
3224 private function mxchat_claude_omits_temperature($model) {
3225 $no_temp = array('claude-opus-4-7', 'claude-opus-4-8');
3226 return in_array($model, $no_temp, true);
3227 }
3228
3229 /**
3230 * Interpret query using Claude models
3231 */
3232 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3233 $url = 'https://api.anthropic.com/v1/messages';
3234
3235 $payload = [
3236 'model' => $model,
3237 'system' => $system_prompt,
3238 'messages' => [
3239 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3240 ],
3241 'max_tokens' => 20,
3242 'temperature' => 0.2,
3243 ];
3244 if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3245
3246 $args = [
3247 'headers' => [
3248 'Content-Type' => 'application/json',
3249 'x-api-key' => $api_key,
3250 'anthropic-version' => '2023-06-01',
3251 ],
3252 'body' => wp_json_encode($payload),
3253 'method' => 'POST',
3254 'timeout' => 15,
3255 ];
3256
3257 $response = wp_remote_post($url, $args);
3258 if (is_wp_error($response)) {
3259 return sanitize_text_field($user_query);
3260 }
3261
3262 $body = json_decode(wp_remote_retrieve_body($response), true);
3263 if (!empty($body['content'][0]['text'])) {
3264 return sanitize_text_field(trim($body['content'][0]['text']));
3265 }
3266
3267 return sanitize_text_field($user_query);
3268 }
3269
3270 /**
3271 * Interpret query using Gemini models
3272 */
3273 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3274 if ($model === 'gemini-3-pro-preview') {
3275 $model = 'gemini-3.1-pro-preview';
3276 }
3277 // Use v1beta for preview models, v1 for stable models
3278 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3279
3280 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
3281
3282 $args = [
3283 'headers' => [
3284 'Content-Type' => 'application/json',
3285 ],
3286 'body' => wp_json_encode([
3287 'contents' => [
3288 [
3289 'role' => 'user',
3290 'parts' => [
3291 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
3292 ]
3293 ]
3294 ],
3295 'generationConfig' => [
3296 'temperature' => 0.2,
3297 'maxOutputTokens' => 20,
3298 ],
3299 ]),
3300 'method' => 'POST',
3301 'timeout' => 15,
3302 ];
3303
3304 $response = wp_remote_post($url, $args);
3305 if (is_wp_error($response)) {
3306 return sanitize_text_field($user_query);
3307 }
3308
3309 $body = json_decode(wp_remote_retrieve_body($response), true);
3310 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
3311 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
3312 }
3313
3314 return sanitize_text_field($user_query);
3315 }
3316
3317 /**
3318 * Interpret query using X.AI (Grok) models
3319 */
3320 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
3321 $url = 'https://api.xai.com/v1/chat/completions';
3322
3323 $args = [
3324 'headers' => [
3325 'Content-Type' => 'application/json',
3326 'Authorization' => 'Bearer ' . $api_key,
3327 ],
3328 'body' => wp_json_encode([
3329 'model' => $model,
3330 'messages' => [
3331 ['role' => 'system', 'content' => $system_prompt],
3332 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3333 ],
3334 'temperature' => 0.2,
3335 'max_tokens' => 20,
3336 ]),
3337 'method' => 'POST',
3338 'timeout' => 15,
3339 ];
3340
3341 $response = wp_remote_post($url, $args);
3342 if (is_wp_error($response)) {
3343 return sanitize_text_field($user_query);
3344 }
3345
3346 $body = json_decode(wp_remote_retrieve_body($response), true);
3347 if (isset($body['choices'][0]['message']['content'])) {
3348 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3349 }
3350
3351 return sanitize_text_field($user_query);
3352 }
3353
3354 /**
3355 * Interpret query using DeepSeek models
3356 */
3357 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3358 $url = 'https://api.deepseek.com/v1/chat/completions';
3359
3360 $args = [
3361 'headers' => [
3362 'Content-Type' => 'application/json',
3363 'Authorization' => 'Bearer ' . $api_key,
3364 ],
3365 'body' => wp_json_encode([
3366 'model' => $model,
3367 'messages' => [
3368 ['role' => 'system', 'content' => $system_prompt],
3369 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3370 ],
3371 'temperature' => 0.2,
3372 'max_tokens' => 20,
3373 ]),
3374 'method' => 'POST',
3375 'timeout' => 15,
3376 ];
3377
3378 $response = wp_remote_post($url, $args);
3379 if (is_wp_error($response)) {
3380 return sanitize_text_field($user_query);
3381 }
3382
3383 $body = json_decode(wp_remote_retrieve_body($response), true);
3384 if (isset($body['choices'][0]['message']['content'])) {
3385 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
3386 }
3387
3388 return sanitize_text_field($user_query);
3389 }
3390
3391 //very good
3392 private function add_email_to_loops($email) {
3393 // Sanitize the email
3394 $email = sanitize_email($email);
3395
3396 // Retrieve and sanitize options
3397 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
3398 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
3399
3400 // Check for missing API key or mailing list ID
3401 if (empty($api_key) || empty($mailing_list_id)) {
3402 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
3403 return;
3404 }
3405
3406 $data = array(
3407 'email' => $email,
3408 'subscribed' => true,
3409 'source' => __('MxChat AI Chatbot', 'mxchat'),
3410 'mailingLists' => array($mailing_list_id => true),
3411 );
3412
3413 $url = 'https://app.loops.so/api/v1/contacts/create';
3414 $args = array(
3415 'body' => wp_json_encode($data),
3416 'headers' => array(
3417 'Authorization' => 'Bearer ' . $api_key,
3418 'Content-Type' => 'application/json',
3419 ),
3420 'method' => 'POST',
3421 'timeout' => 45,
3422 );
3423
3424 $response = wp_remote_post($url, $args);
3425
3426 // Handle errors in the API request
3427 if (is_wp_error($response)) {
3428 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
3429 return;
3430 }
3431
3432 // Check for non-200 HTTP responses
3433 $response_code = wp_remote_retrieve_response_code($response);
3434 if ($response_code != 200) {
3435 $response_body = wp_remote_retrieve_body($response);
3436 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
3437 }
3438 }
3439
3440 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
3441 // Get the maximum number of pages allowed from admin settings
3442 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3443
3444 // Retrieve options for dynamic texts
3445 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
3446 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
3447 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
3448
3449 // Check for explicit request for new PDF
3450 $new_pdf_requested = stripos($message, 'new') !== false ||
3451 stripos($message, 'another') !== false ||
3452 stripos($message, 'different') !== false;
3453
3454 // If user mentions adding/reading a PDF, set waiting flag
3455 if (stripos($message, 'pdf') !== false ||
3456 stripos($message, 'document') !== false ||
3457 stripos($message, 'read') !== false) {
3458 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
3459 $this->fallbackResponse['text'] = $trigger_text;
3460 return;
3461 }
3462
3463 // If we're waiting for a URL or user requested new PDF
3464 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
3465 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
3466 // Process URL... (rest of your existing URL processing code)
3467 } else {
3468 $this->fallbackResponse['text'] = $trigger_text;
3469 }
3470 return;
3471 }
3472
3473 // Default to proceeding with conversation if no specific PDF action is needed
3474 $this->fallbackResponse['text'] = '';
3475 }
3476
3477
3478 /**
3479 * Enhanced fetch_and_split_pdf_pages with SSRF protection
3480 */
3481 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3482 // CLEAR DEBUG LOGGING
3483 //error_log("=== MXCHAT PDF PROCESSING START ===");
3484 //error_log("PDF Source: " . $pdf_source);
3485 //error_log("Max Pages: " . $max_pages);
3486 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
3487
3488 // Check if Advanced Claude Toolbar is available and enabled
3489 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
3490 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
3491
3492 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
3493 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
3494
3495 if ($claude_available && $claude_enabled) {
3496 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
3497
3498 // Attempt Claude processing first
3499 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
3500
3501 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
3502 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
3503 //error_log("Claude returned " . count($claude_result) . " processed pages");
3504
3505 // Log first page details for verification
3506 if (isset($claude_result[0])) {
3507 $first_page = $claude_result[0];
3508 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
3509 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
3510 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
3511 }
3512
3513 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
3514 return $claude_result;
3515 } else {
3516 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
3517 //error_log("Claude result type: " . gettype($claude_result));
3518 if (is_array($claude_result)) {
3519 //error_log("Claude result count: " . count($claude_result));
3520 }
3521 }
3522 }
3523
3524 // Fallback to basic processing
3525 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
3526
3527 $upload_dir = wp_upload_dir();
3528 $temp_file = null;
3529
3530 try {
3531 // Your existing basic processing code here...
3532 // (I'll include the key parts with debug logging)
3533
3534 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
3535 //error_log("Downloading PDF from URL...");
3536
3537 // SECURITY FIX: Validate URL before processing
3538 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
3539 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
3540 return false;
3541 }
3542
3543 $temp_file = wp_tempnam($pdf_source);
3544
3545 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
3546 $response = wp_safe_remote_get($pdf_source, [
3547 'timeout' => 60,
3548 'headers' => ['User-Agent' => 'MxChat PDF Processor']
3549 ]);
3550
3551 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
3552 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
3553 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
3554 return false;
3555 }
3556
3557 global $wp_filesystem;
3558 if (empty($wp_filesystem)) {
3559 require_once ABSPATH . 'wp-admin/includes/file.php';
3560 WP_Filesystem();
3561 }
3562 $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
3563 //error_log("✅ PDF downloaded successfully");
3564 } else {
3565 $temp_file = $pdf_source;
3566 //error_log("Using local PDF file: " . $temp_file);
3567 }
3568
3569 // Parse PDF
3570 //error_log("Parsing PDF with basic parser...");
3571 mxchat_load_pdf_parser();
3572 $parser = new \Smalot\PdfParser\Parser();
3573 $pdf = $parser->parseFile($temp_file);
3574 $pages = $pdf->getPages();
3575
3576 //error_log("PDF contains " . count($pages) . " pages");
3577
3578 if (count($pages) > $max_pages) {
3579 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
3580 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
3581 unlink($temp_file);
3582 }
3583 return 'too_many_pages';
3584 }
3585
3586 $embeddings = [];
3587 $processed_pages = 0;
3588
3589 foreach ($pages as $page_number => $page) {
3590 $text = $page->getText();
3591
3592 if (empty(trim($text))) {
3593 //error_log("Skipping empty page: " . ($page_number + 1));
3594 continue;
3595 }
3596
3597 $text = $this->mxchat_clean_text($text);
3598
3599 $embedding = $this->mxchat_generate_embedding(
3600 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
3601 $this->options['api_key']
3602 );
3603
3604 if ($embedding) {
3605 $embeddings[] = [
3606 'page_number' => $page_number + 1,
3607 'embedding' => $embedding,
3608 'text' => $text,
3609 'enhanced' => false, // CLEARLY MARK AS BASIC
3610 'processing_method' => 'basic_pdf_parser'
3611 ];
3612 $processed_pages++;
3613 }
3614 }
3615
3616 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3617
3618 // Cleanup
3619 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3620 unlink($temp_file);
3621 }
3622
3623 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3624 return $embeddings;
3625
3626 } catch (\Exception $e) {
3627 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3628 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3629 unlink($temp_file);
3630 }
3631 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3632 return false;
3633 }
3634 }
3635
3636
3637 /**
3638 * Validate PDF URL for security
3639 * Prevents SSRF attacks by blocking dangerous URLs
3640 */
3641
3642 private function mxchat_is_safe_pdf_url($url) {
3643 // Use WordPress core function for comprehensive validation
3644 // This blocks localhost, private IPs, and reserved IP ranges
3645 $validated_url = wp_http_validate_url($url);
3646
3647 if ($validated_url === false) {
3648 return false;
3649 }
3650
3651 // Additional check: only allow HTTP/HTTPS schemes
3652 $parsed = parse_url($url);
3653 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3654 return false;
3655 }
3656
3657 return true;
3658 }
3659
3660
3661 private function mxchat_clean_text($text) {
3662 // Remove excessive whitespace
3663 $text = preg_replace('/\s+/', ' ', $text);
3664
3665 // Remove control characters except newlines and tabs
3666 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3667
3668 // Normalize line endings
3669 $text = str_replace(["\r\n", "\r"], "\n", $text);
3670
3671 // Trim whitespace
3672 $text = trim($text);
3673
3674 return $text;
3675 }
3676
3677 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3678 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3679
3680 $most_relevant = null;
3681 $highest_similarity = -INF;
3682
3683 foreach ($embeddings as $page_data) {
3684 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3685
3686 if ($similarity > $highest_similarity) {
3687 $highest_similarity = $similarity;
3688 $most_relevant = $page_data['page_number'];
3689 }
3690 }
3691
3692 if (!is_null($most_relevant)) {
3693 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3694 return array_filter($embeddings, function ($page) use ($page_numbers) {
3695 return in_array($page['page_number'], $page_numbers);
3696 });
3697 }
3698
3699 return [];
3700 }
3701
3702
3703 public function handle_pdf_upload() {
3704 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
3705 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
3706 }
3707
3708 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3709 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3710 return;
3711 }
3712
3713 // SECURITY FIX: Check if PDF uploads are enabled in settings
3714 $options = get_option('mxchat_options', array());
3715 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3716
3717 if ($show_pdf_button !== 'on') {
3718 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3719 return;
3720 }
3721
3722 $file = $_FILES['pdf_file'];
3723 $session_id = sanitize_text_field($_POST['session_id']);
3724 $original_filename = sanitize_text_field($file['name']);
3725
3726 // Update session owner if it changed (e.g. IP changed due to network switch)
3727 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3728 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3729
3730 if (!$session_owner || $session_owner !== $current_user_identifier) {
3731 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
3732 }
3733
3734 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3735 if ($file_type['type'] !== 'application/pdf') {
3736 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3737 return;
3738 }
3739
3740 $upload_dir = wp_upload_dir();
3741
3742 // SECURITY FIX: Generate random filename without exposing session_id
3743 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3744 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3745 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3746
3747 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3748 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3749 return;
3750 }
3751
3752 $this->clear_pdf_transients($session_id);
3753
3754 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3755 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3756
3757 if ($embeddings === 'too_many_pages') {
3758 unlink($pdf_path);
3759 $error_message = sprintf(
3760 $this->options['pdf_intent_error_text'] ??
3761 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3762 $max_pages
3763 );
3764 wp_send_json_error($error_message);
3765 return;
3766 }
3767
3768 if ($embeddings === false || empty($embeddings)) {
3769 unlink($pdf_path);
3770 $error_message = $this->options['pdf_intent_error_text'] ??
3771 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3772 wp_send_json_error($error_message);
3773 return;
3774 }
3775
3776 if (!empty($embeddings)) {
3777 // Store the mapping between session and the random filename
3778 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3779 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3780 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3781 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3782
3783 $success_message = $this->options['pdf_intent_success_text'] ??
3784 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3785
3786 wp_send_json_success([
3787 'message' => $success_message,
3788 'filename' => $original_filename
3789 ]);
3790 return;
3791 }
3792
3793 unlink($pdf_path);
3794 $error_message = $this->options['pdf_intent_error_text'] ??
3795 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3796 wp_send_json_error($error_message);
3797 return;
3798 }
3799 public function handle_pdf_remove() {
3800 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
3801 wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
3802 }
3803
3804 if (empty($_POST['session_id'])) {
3805 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3806 wp_die();
3807 }
3808
3809 $session_id = sanitize_text_field($_POST['session_id']);
3810 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3811
3812 if ($pdf_path && file_exists($pdf_path)) {
3813 unlink($pdf_path);
3814 }
3815
3816 $this->clear_pdf_transients($session_id);
3817
3818 wp_send_json_success([
3819 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3820 ]);
3821 wp_die();
3822 }
3823
3824
3825 function mxchat_fetch_new_messages() {
3826 $session_id = sanitize_text_field($_POST['session_id']);
3827 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3828 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3829 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3830
3831 if (empty($session_id)) {
3832 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3833 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3834 wp_die();
3835 }
3836
3837 $history = get_option("mxchat_history_{$session_id}", []);
3838
3839 //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3840 //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3841 //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3842 //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3843
3844 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3845 //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3846
3847 // If persistence is enabled, show all new messages
3848 if ($persistence_enabled) {
3849 $has_id = !empty($message['id']);
3850 $is_agent = $message['role'] === 'agent';
3851
3852 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3853 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3854 $is_newer = true;
3855 } else {
3856 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3857 }
3858
3859 //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3860
3861 return $has_id && $is_newer && $is_agent;
3862 }
3863
3864 // If persistence is disabled, only show messages after initial timestamp
3865 return !empty($message['id']) &&
3866 $message['role'] === 'agent' &&
3867 $message['timestamp'] > $initial_timestamp;
3868 });
3869
3870 //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3871
3872 // Include current chat mode so frontend can detect agent→AI transitions
3873 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
3874
3875 wp_send_json_success([
3876 'new_messages' => array_values($new_messages),
3877 'chat_mode' => $chat_mode
3878 ]);
3879 wp_die();
3880 }
3881 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3882 // First check if live agents are available
3883 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3884 if ($live_agent_available !== 'on') {
3885 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3886 $this->fallbackResponse = [
3887 'text' => $away_message,
3888 'html' => '',
3889 'images' => [],
3890 'chat_mode' => 'ai'
3891 ];
3892 wp_send_json([
3893 'text' => $away_message,
3894 'html' => '',
3895 'chat_mode' => 'ai',
3896 'session_id' => $session_id
3897 ]);
3898 wp_die();
3899 }
3900
3901 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3902
3903 if (empty($slack_bot_token)) {
3904 return false;
3905 }
3906
3907 // Check if channel already exists for this session
3908 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3909
3910 if (empty($channel_id)) {
3911 // Create new channel with session ID as name
3912 $channel_name = $this->generate_channel_name($session_id);
3913
3914 //error_log("Attempting to create channel: $channel_name");
3915
3916 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3917 'headers' => [
3918 'Content-Type' => 'application/json',
3919 'Authorization' => 'Bearer ' . $slack_bot_token
3920 ],
3921 'body' => json_encode([
3922 'name' => $channel_name,
3923 'is_private' => false // Public channel - anyone in workspace can join
3924 ])
3925 ]);
3926
3927 if (!is_wp_error($response)) {
3928 $response_body = wp_remote_retrieve_body($response);
3929 $response_data = json_decode($response_body, true);
3930
3931 //error_log("Channel creation response: " . $response_body);
3932
3933 if (isset($response_data['ok']) && $response_data['ok']) {
3934 $channel_id = $response_data['channel']['id'];
3935 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3936 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3937 update_option("mxchat_channel_{$session_id}", $channel_id);
3938
3939 // Auto-invite agents to the channel
3940 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3941
3942 if (!empty($agent_user_ids)) {
3943 // Parse user IDs (one per line)
3944 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3945
3946 foreach ($user_ids as $user_id_to_invite) {
3947 //error_log("Inviting user to channel: $user_id_to_invite");
3948
3949 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3950 'headers' => [
3951 'Content-Type' => 'application/json',
3952 'Authorization' => 'Bearer ' . $slack_bot_token
3953 ],
3954 'body' => json_encode([
3955 'channel' => $channel_id,
3956 'users' => $user_id_to_invite
3957 ])
3958 ]);
3959
3960 if (!is_wp_error($invite_response)) {
3961 $invite_body = wp_remote_retrieve_body($invite_response);
3962 $invite_data = json_decode($invite_body, true);
3963 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3964
3965 if (isset($invite_data['ok']) && $invite_data['ok']) {
3966 //error_log("Successfully invited user $user_id_to_invite to channel");
3967 } else {
3968 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3969 }
3970 } else {
3971 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3972 }
3973 }
3974 } else {
3975 //error_log("No agent user IDs configured for auto-invite");
3976 }
3977 } else {
3978 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3979 }
3980 } else {
3981 //error_log("WP Error creating channel: " . $response->get_error_message());
3982 }
3983
3984 if (empty($channel_id)) {
3985 return false; // Failed to create channel
3986 }
3987 }
3988
3989 // Get recent chat history
3990 $history = get_option("mxchat_history_{$session_id}", []);
3991 $recent_history = array_slice($history, -5);
3992
3993 // Format conversation context
3994 $conversation_context = "";
3995 if (!empty($recent_history)) {
3996 $conversation_context = "*Recent Conversation:*\n";
3997 foreach ($recent_history as $hist_message) {
3998 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3999 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
4000 }
4001 $conversation_context .= "\n";
4002 }
4003
4004 update_option("mxchat_mode_{$session_id}", 'agent');
4005
4006 // Send message to channel
4007 $channel_message = "🔔 *New Live Agent Request*\n\n";
4008 $channel_message .= "*Session ID:* `{$session_id}`\n";
4009 $channel_message .= "*User ID:* `{$user_id}`\n\n";
4010
4011 if (!empty($conversation_context)) {
4012 $channel_message .= $conversation_context;
4013 }
4014
4015 $channel_message .= "*Current Message:*\n{$message}\n\n";
4016 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4017
4018 wp_remote_post('https://slack.com/api/chat.postMessage', [
4019 'headers' => [
4020 'Content-Type' => 'application/json',
4021 'Authorization' => 'Bearer ' . $slack_bot_token
4022 ],
4023 'body' => json_encode([
4024 'channel' => $channel_id,
4025 'text' => $channel_message,
4026 'mrkdwn' => true
4027 ])
4028 ]);
4029
4030 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4031 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4032
4033 $this->fallbackResponse = [
4034 'text' => $success_message,
4035 'html' => '',
4036 'images' => [],
4037 'chat_mode' => 'agent'
4038 ];
4039
4040 wp_send_json([
4041 'success' => true,
4042 'text' => $success_message,
4043 'html' => '',
4044 'chat_mode' => 'agent',
4045 'session_id' => $session_id,
4046 'fallbackResponse' => $this->fallbackResponse
4047 ]);
4048 wp_die();
4049 }
4050
4051 private function generate_channel_name($session_id) {
4052 $email = null;
4053 $name = null;
4054
4055 // 1. First priority: Check if user is logged in and get their info
4056 if (is_user_logged_in()) {
4057 $current_user = wp_get_current_user();
4058 if (!empty($current_user->user_email)) {
4059 $email = $current_user->user_email;
4060 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
4061 }
4062 if (!empty($current_user->display_name)) {
4063 $name = $current_user->display_name;
4064 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
4065 }
4066 }
4067
4068 // 2. Second priority: Check for saved email/name from "require email to chat" option
4069 if (empty($email)) {
4070 $email_option_key = "mxchat_email_{$session_id}";
4071 $saved_email = get_option($email_option_key);
4072 if (!empty($saved_email)) {
4073 $email = $saved_email;
4074 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
4075 }
4076 }
4077
4078 if (empty($name)) {
4079 $name_option_key = "mxchat_name_{$session_id}";
4080 $saved_name = get_option($name_option_key);
4081 if (!empty($saved_name)) {
4082 $name = $saved_name;
4083 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
4084 }
4085 }
4086
4087 // 3. Third priority: Check existing chat transcript for email/name
4088 if (empty($email) || empty($name)) {
4089 global $wpdb;
4090 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4091 $existing_data = $wpdb->get_row($wpdb->prepare(
4092 "SELECT user_email, user_name FROM $table_name WHERE session_id = %s AND (user_email IS NOT NULL OR user_name IS NOT NULL) LIMIT 1",
4093 $session_id
4094 ));
4095
4096 if ($existing_data) {
4097 if (empty($email) && !empty($existing_data->user_email)) {
4098 $email = $existing_data->user_email;
4099 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
4100 }
4101 if (empty($name) && !empty($existing_data->user_name)) {
4102 $name = $existing_data->user_name;
4103 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
4104 }
4105 }
4106 }
4107
4108 // 4. Generate channel name based on priority: Name > Email > Session ID
4109 $channel_name = '';
4110
4111 if (!empty($name)) {
4112 // Convert name to valid Slack channel name
4113 $base_name = strtolower(trim($name));
4114 // Replace spaces and invalid characters
4115 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
4116 $base_name = preg_replace('/\s+/', '-', $base_name);
4117 $base_name = trim($base_name, '-');
4118
4119 // Get last 4 characters of session ID for uniqueness
4120 $session_suffix = substr($session_id, -4);
4121 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
4122
4123 // Slack channel names have a 21 character limit
4124 if (strlen($channel_name) > 21) {
4125 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
4126 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
4127 $truncated_name = substr($base_name, 0, $available_space);
4128 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
4129 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
4130 }
4131
4132 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
4133
4134 } elseif (!empty($email)) {
4135 // Convert email to valid Slack channel name (your existing logic)
4136 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
4137 // Remove any remaining invalid characters
4138 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
4139 // Ensure it doesn't end with a hyphen
4140 $channel_name = rtrim($channel_name, '-');
4141 // Slack channel names have a 21 character limit, so truncate if needed
4142 if (strlen($channel_name) > 21) {
4143 $channel_name = substr($channel_name, 0, 21);
4144 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
4145 }
4146
4147 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
4148
4149 } else {
4150 // Fallback to session ID if no name or email found
4151 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
4152 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
4153 }
4154
4155 // Final validation - ensure channel name meets Slack requirements
4156 if (strlen($channel_name) > 21) {
4157 $channel_name = substr($channel_name, 0, 21);
4158 $channel_name = rtrim($channel_name, '-');
4159 }
4160
4161 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4162 return $channel_name;
4163 }
4164
4165 /**
4166 * Telegram Live Agent Handover
4167 * Creates a forum topic in the Telegram group and notifies agents
4168 */
4169 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4170 // Check if Telegram agents are available
4171 $telegram_available = $this->options['telegram_status'] ?? 'off';
4172 if ($telegram_available !== 'on') {
4173 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4174 $this->fallbackResponse = [
4175 'text' => $away_message,
4176 'html' => '',
4177 'images' => [],
4178 'chat_mode' => 'ai'
4179 ];
4180 wp_send_json([
4181 'text' => $away_message,
4182 'html' => '',
4183 'chat_mode' => 'ai',
4184 'session_id' => $session_id
4185 ]);
4186 wp_die();
4187 }
4188
4189 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4190 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4191
4192 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4193 return false;
4194 }
4195
4196 // Check if topic already exists for this session
4197 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4198
4199 if (empty($topic_id)) {
4200 // Generate topic name
4201 $topic_name = $this->generate_telegram_topic_name($session_id);
4202
4203 // Random icon color (Telegram forum topic colors)
4204 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4205 $icon_color = $icon_colors[array_rand($icon_colors)];
4206
4207 // Create forum topic
4208 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4209 'headers' => ['Content-Type' => 'application/json'],
4210 'body' => json_encode([
4211 'chat_id' => $telegram_group_id,
4212 'name' => $topic_name,
4213 'icon_color' => $icon_color
4214 ])
4215 ]);
4216
4217 if (!is_wp_error($response)) {
4218 $response_body = wp_remote_retrieve_body($response);
4219 $response_data = json_decode($response_body, true);
4220
4221 if (isset($response_data['ok']) && $response_data['ok']) {
4222 $topic_id = $response_data['result']['message_thread_id'];
4223 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
4224 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
4225 }
4226 }
4227
4228 if (empty($topic_id)) {
4229 return false; // Failed to create topic
4230 }
4231 }
4232
4233 // Get recent chat history
4234 $history = get_option("mxchat_history_{$session_id}", []);
4235 $recent_history = array_slice($history, -5);
4236
4237 // Format conversation context for Telegram (HTML format)
4238 $conversation_context = "";
4239 if (!empty($recent_history)) {
4240 $conversation_context = "<b>Recent Conversation:</b>\n";
4241 foreach ($recent_history as $hist_message) {
4242 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
4243 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
4244 $conversation_context .= "{$role_display}: {$escaped_content}\n";
4245 }
4246 $conversation_context .= "\n";
4247 }
4248
4249 // Get user info
4250 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
4251 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
4252
4253 // Update session mode
4254 update_option("mxchat_mode_{$session_id}", 'agent');
4255
4256 // Send initial message to topic
4257 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4258 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
4259 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
4260 $topic_message .= "<b>User:</b> {$user_name}\n";
4261 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
4262
4263 if (!empty($conversation_context)) {
4264 $topic_message .= $conversation_context;
4265 }
4266
4267 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
4268 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
4269 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
4270
4271 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4272 'headers' => ['Content-Type' => 'application/json'],
4273 'body' => json_encode([
4274 'chat_id' => $telegram_group_id,
4275 'message_thread_id' => $topic_id,
4276 'text' => $topic_message,
4277 'parse_mode' => 'HTML'
4278 ])
4279 ]);
4280
4281 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
4282 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4283
4284 $this->fallbackResponse = [
4285 'text' => $success_message,
4286 'html' => '',
4287 'images' => [],
4288 'chat_mode' => 'agent'
4289 ];
4290
4291 wp_send_json([
4292 'success' => true,
4293 'text' => $success_message,
4294 'html' => '',
4295 'chat_mode' => 'agent',
4296 'session_id' => $session_id,
4297 'fallbackResponse' => $this->fallbackResponse
4298 ]);
4299 wp_die();
4300 }
4301
4302 /**
4303 * Generate topic name for Telegram forum
4304 */
4305 private function generate_telegram_topic_name($session_id) {
4306 $name = null;
4307 $email = null;
4308
4309 // Check logged in user
4310 if (is_user_logged_in()) {
4311 $current_user = wp_get_current_user();
4312 if (!empty($current_user->display_name)) {
4313 $name = $current_user->display_name;
4314 }
4315 if (!empty($current_user->user_email)) {
4316 $email = $current_user->user_email;
4317 }
4318 }
4319
4320 // Check session data
4321 if (empty($name)) {
4322 $name = get_option("mxchat_name_{$session_id}");
4323 }
4324 if (empty($email)) {
4325 $email = get_option("mxchat_email_{$session_id}");
4326 }
4327
4328 // Generate topic name
4329 $session_suffix = substr($session_id, -6);
4330
4331 if (!empty($name)) {
4332 // Clean name for topic (max 128 chars in Telegram)
4333 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
4334 $clean_name = trim($clean_name);
4335 if (strlen($clean_name) > 50) {
4336 $clean_name = substr($clean_name, 0, 50);
4337 }
4338 return "Chat - {$clean_name} ({$session_suffix})";
4339 } elseif (!empty($email)) {
4340 // Use email prefix
4341 $email_prefix = explode('@', $email)[0];
4342 if (strlen($email_prefix) > 30) {
4343 $email_prefix = substr($email_prefix, 0, 30);
4344 }
4345 return "Chat - {$email_prefix} ({$session_suffix})";
4346 }
4347
4348 return "Chat - {$session_suffix}";
4349 }
4350
4351 /**
4352 * Send user message to Telegram agent
4353 */
4354 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
4355 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4356 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4357 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4358
4359 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
4360 return false;
4361 }
4362
4363 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4364 $user_message = "👤 <b>User:</b> {$escaped_message}";
4365
4366 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4367 'headers' => ['Content-Type' => 'application/json'],
4368 'body' => json_encode([
4369 'chat_id' => $group_id,
4370 'message_thread_id' => $topic_id,
4371 'text' => $user_message,
4372 'parse_mode' => 'HTML'
4373 ])
4374 ]);
4375
4376 return !is_wp_error($response);
4377 }
4378
4379 /**
4380 * Handle incoming Telegram webhook
4381 */
4382 public function handle_telegram_webhook(WP_REST_Request $request) {
4383 $body = $request->get_body();
4384 $data = json_decode($body, true);
4385
4386 //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
4387
4388 // Handle message events from forum topics
4389 if (isset($data['message'])) {
4390 $message_data = $data['message'];
4391
4392 // Skip if not from a forum topic
4393 if (!isset($message_data['message_thread_id'])) {
4394 //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
4395 return new WP_REST_Response(['ok' => true]);
4396 }
4397
4398 // Skip bot messages
4399 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
4400 //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
4401 return new WP_REST_Response(['ok' => true]);
4402 }
4403
4404 $chat_id = $message_data['chat']['id'] ?? '';
4405 $topic_id = $message_data['message_thread_id'];
4406 $message_text = $message_data['text'] ?? '';
4407 $message_id = $message_data['message_id'] ?? '';
4408 $from = $message_data['from'] ?? [];
4409 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
4410 if (empty($agent_name)) {
4411 $agent_name = $from['username'] ?? 'Agent';
4412 }
4413
4414 //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
4415
4416 // Skip empty messages
4417 if (empty($message_text)) {
4418 //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
4419 return new WP_REST_Response(['ok' => true]);
4420 }
4421
4422 // Find session ID by topic ID - cast to string for comparison
4423 global $wpdb;
4424 $topic_id_str = strval($topic_id);
4425 $session_option = $wpdb->get_var(
4426 $wpdb->prepare(
4427 "SELECT option_name FROM {$wpdb->options}
4428 WHERE option_name LIKE %s
4429 AND option_value = %s",
4430 'mxchat_telegram_topic_%',
4431 $topic_id_str
4432 )
4433 );
4434
4435 //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
4436
4437 if ($session_option) {
4438 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
4439 //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
4440
4441 // Verify the group ID matches
4442 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
4443 //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
4444
4445 if (strval($stored_group_id) != strval($chat_id)) {
4446 //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
4447 return new WP_REST_Response(['ok' => true]);
4448 }
4449
4450 // Check for closure commands
4451 $lower_text = strtolower(trim($message_text));
4452 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
4453 //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
4454 // End the live agent session
4455 update_option("mxchat_mode_{$session_id}", 'ai');
4456
4457 // Save disconnect message
4458 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
4459 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
4460
4461 // Notify in Telegram
4462 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4463 if (!empty($telegram_bot_token)) {
4464 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4465 'headers' => ['Content-Type' => 'application/json'],
4466 'body' => json_encode([
4467 'chat_id' => $chat_id,
4468 'message_thread_id' => $topic_id,
4469 'text' => "✅ Session closed. User returned to AI chatbot.",
4470 'parse_mode' => 'HTML'
4471 ])
4472 ]);
4473
4474 // Optionally close the topic
4475 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
4476 'headers' => ['Content-Type' => 'application/json'],
4477 'body' => json_encode([
4478 'chat_id' => $chat_id,
4479 'message_thread_id' => $topic_id
4480 ])
4481 ]);
4482 }
4483
4484 return new WP_REST_Response(['ok' => true]);
4485 }
4486
4487 // Deduplicate messages
4488 $message_key = md5($session_id . $message_id . $message_text);
4489 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
4490
4491 if (in_array($message_key, $processed_messages)) {
4492 //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
4493 return new WP_REST_Response(['ok' => true]);
4494 }
4495
4496 $processed_messages[] = $message_key;
4497 if (count($processed_messages) > 50) {
4498 $processed_messages = array_slice($processed_messages, -50);
4499 }
4500 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4501
4502 // Save the agent message - format with agent name prefix for proper parsing
4503 $formatted_message = "Agent: {$agent_name} - {$message_text}";
4504 //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
4505
4506 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
4507
4508 // Verify the message was saved to history
4509 $history = get_option("mxchat_history_{$session_id}", []);
4510 $last_message = end($history);
4511 //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
4512
4513 // Send confirmation back to Telegram
4514 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4515 if (!empty($telegram_bot_token)) {
4516 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
4517 if (!get_transient($confirm_key)) {
4518 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4519 'headers' => ['Content-Type' => 'application/json'],
4520 'body' => json_encode([
4521 'chat_id' => $chat_id,
4522 'message_thread_id' => $topic_id,
4523 'text' => "✅ <i>Message sent to user</i>",
4524 'parse_mode' => 'HTML',
4525 'reply_to_message_id' => $message_id
4526 ])
4527 ]);
4528 set_transient($confirm_key, true, 300);
4529 }
4530 }
4531 } else {
4532 //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
4533 }
4534 } else {
4535 //error_log('[MxChat Telegram DEBUG] No message in webhook data');
4536 }
4537
4538 return new WP_REST_Response(['ok' => true]);
4539 }
4540
4541 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
4542 // Check if this is a Telegram agent session
4543 $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4544 if (!empty($telegram_topic_id)) {
4545 return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
4546 }
4547
4548 // Otherwise, try Slack
4549 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4550 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4551
4552 if (empty($slack_bot_token) || empty($channel_id)) {
4553 return false;
4554 }
4555
4556 $user_message = "💬 *User:* {$message}";
4557
4558 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4559 'headers' => [
4560 'Content-Type' => 'application/json',
4561 'Authorization' => 'Bearer ' . $slack_bot_token
4562 ],
4563 'body' => json_encode([
4564 'channel' => $channel_id,
4565 'text' => $user_message,
4566 'mrkdwn' => true
4567 ])
4568 ]);
4569
4570 return !is_wp_error($response);
4571 }
4572 public function handle_slack_interaction(WP_REST_Request $request) {
4573 //error_log('Received Slack interaction');
4574
4575 $payload = json_decode($request->get_param('payload'), true);
4576 //error_log('Payload: ' . print_r($payload, true));
4577
4578 // Handle button click
4579 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
4580 $session_id = $payload['actions'][0]['value'];
4581 $trigger_id = $payload['trigger_id'];
4582
4583 // Get Bot Token from settings
4584 $slack_token = $this->options['live_agent_bot_token'] ?? '';
4585
4586 if (empty($slack_token)) {
4587 //error_log('Slack Bot Token not configured');
4588 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
4589 }
4590 $response = wp_remote_post('https://slack.com/api/views.open', [
4591 'headers' => [
4592 'Content-Type' => 'application/json',
4593 'Authorization' => 'Bearer ' . $slack_token
4594 ],
4595 'body' => json_encode([
4596 'trigger_id' => $trigger_id,
4597 'view' => [
4598 'type' => 'modal',
4599 'callback_id' => 'reply_modal',
4600 'title' => [
4601 'type' => 'plain_text',
4602 'text' => __('Reply to User', 'mxchat')
4603 ],
4604 'submit' => [
4605 'type' => 'plain_text',
4606 'text' => __('Send', 'mxchat')
4607 ],
4608 'close' => [
4609 'type' => 'plain_text',
4610 'text' => __('Cancel', 'mxchat')
4611 ],
4612 'blocks' => [
4613 [
4614 'type' => 'input',
4615 'block_id' => 'reply_block',
4616 'label' => [
4617 'type' => 'plain_text',
4618 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
4619 ],
4620 'element' => [
4621 'type' => 'plain_text_input',
4622 'action_id' => 'message',
4623 'multiline' => true,
4624 'placeholder' => [
4625 'type' => 'plain_text',
4626 'text' => __('Type your message here...', 'mxchat')
4627 ]
4628 ]
4629 ]
4630 ],
4631 'private_metadata' => $session_id
4632 ]
4633 ])
4634 ]);
4635
4636 //error_log('Views.open response: ' . print_r($response, true));
4637
4638 // Return immediate acknowledgment
4639 return new WP_REST_Response(['ok' => true]);
4640 }
4641
4642 // Handle modal submission
4643 // Handle modal submission
4644 if ($payload['type'] === 'view_submission') {
4645 $session_id = $payload['view']['private_metadata'];
4646 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4647
4648 // Save the message (keep the message_id but don't include in response)
4649 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4650
4651 // Keep the original response format for Slack
4652 return new WP_REST_Response([
4653 'response_action' => 'clear'
4654 ]);
4655 }
4656
4657 // Default acknowledgment
4658 return new WP_REST_Response(['ok' => true]);
4659 }
4660 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4661 //error_log('Received agent response request');
4662 //error_log('Request data: ' . print_r($request->get_params(), true));
4663 // //error_log('Raw body: ' . file_get_contents('php://input'));
4664
4665 // Get the data from Slack's slash command format
4666 $command_text = $request->get_param('text');
4667 // //error_log('Command text: ' . $command_text);
4668
4669 if (empty($command_text)) {
4670 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4671 return new WP_REST_Response([
4672 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4673 ], 400);
4674 }
4675
4676 // Split the command text into session_id and message
4677 $parts = explode(' ', $command_text, 2);
4678 if (count($parts) !== 2) {
4679 //error_log('Agent response error: Invalid command format');
4680 return new WP_REST_Response([
4681 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4682 ], 400);
4683 }
4684
4685 $session_id = sanitize_text_field($parts[0]);
4686 $message = sanitize_text_field($parts[1]);
4687
4688 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4689
4690 // Save the message
4691 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4692
4693 if (!$message_id) {
4694 // //error_log('Failed to save agent message');
4695 return new WP_REST_Response([
4696 'error' => esc_html__('Failed to save message', 'mxchat')
4697 ], 500);
4698 }
4699
4700 // Return success response in Slack's expected format
4701 return new WP_REST_Response([
4702 'response_type' => 'in_channel',
4703 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4704 ], 200);
4705 }
4706 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4707 // Update mode to AI
4708 update_option("mxchat_mode_{$session_id}", 'ai');
4709
4710 // Clear any existing PDF context to start fresh
4711 $this->clear_pdf_transients($session_id);
4712
4713 // Set the response with explicit chat_mode
4714 $this->fallbackResponse = [
4715 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4716 'html' => '',
4717 'images' => [],
4718 'chat_mode' => 'ai' // Ensure this is set
4719 ];
4720
4721 // Return the complete response array instead of just true
4722 return $this->fallbackResponse;
4723 }
4724
4725 public function handle_slack_messages(WP_REST_Request $request) {
4726 // Log the incoming request for debugging
4727 //error_log('Slack events request received: ' . $request->get_body());
4728
4729 $body = $request->get_body();
4730 $data = json_decode($body, true);
4731
4732 // Handle Slack URL verification
4733 if (isset($data['type']) && $data['type'] === 'url_verification') {
4734 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4735 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4736 }
4737
4738 // IMPORTANT: Handle Slack's event deduplication
4739 if (isset($data['event_id'])) {
4740 $event_id = $data['event_id'];
4741 $processed_events = get_transient('mxchat_slack_events') ?: [];
4742
4743 // Check if we've already processed this event
4744 if (in_array($event_id, $processed_events)) {
4745 //error_log("Duplicate event detected: $event_id");
4746 return new WP_REST_Response(['ok' => true]);
4747 }
4748
4749 // Add this event to processed list
4750 $processed_events[] = $event_id;
4751 // Keep only last 100 events to prevent memory issues
4752 if (count($processed_events) > 100) {
4753 $processed_events = array_slice($processed_events, -100);
4754 }
4755 // Store for 1 hour
4756 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4757 }
4758
4759 // Handle message events
4760 if (isset($data['event']) && $data['event']['type'] === 'message') {
4761 $event = $data['event'];
4762
4763 // Skip bot messages and messages with subtypes (like bot_message)
4764 if (isset($event['bot_id']) || isset($event['subtype'])) {
4765 return new WP_REST_Response(['ok' => true]);
4766 }
4767
4768 // Additional check: Skip if this is a threaded reply to our confirmation
4769 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4770 return new WP_REST_Response(['ok' => true]);
4771 }
4772
4773 $channel_id = $event['channel'];
4774 $message_text = $event['text'] ?? '';
4775 $message_ts = $event['ts'] ?? '';
4776
4777 // Find session ID by looking for matching channel
4778 global $wpdb;
4779 $session_option = $wpdb->get_var(
4780 $wpdb->prepare(
4781 "SELECT option_name FROM {$wpdb->options}
4782 WHERE option_name LIKE 'mxchat_channel_%'
4783 AND option_value = %s",
4784 $channel_id
4785 )
4786 );
4787
4788 if ($session_option) {
4789 $session_id = str_replace('mxchat_channel_', '', $session_option);
4790
4791 // Create a unique key for this specific message
4792 $message_key = md5($session_id . $message_ts . $message_text);
4793 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4794
4795 // Check if we've already processed this exact message
4796 if (in_array($message_key, $processed_messages)) {
4797 //error_log("Duplicate message detected for session $session_id");
4798 return new WP_REST_Response(['ok' => true]);
4799 }
4800
4801 // Add to processed messages
4802 $processed_messages[] = $message_key;
4803 // Keep only last 50 messages per session
4804 if (count($processed_messages) > 50) {
4805 $processed_messages = array_slice($processed_messages, -50);
4806 }
4807 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4808
4809 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4810
4811 // Handle agent ending the chat — transfer back to AI
4812 // Format: "!endchat" or "!endchat <custom message to user>"
4813 if (preg_match('/^!endchat\b/i', trim($message_text))) {
4814 update_option("mxchat_mode_{$session_id}", 'ai');
4815
4816 // Extract custom message after !endchat, or use empty string
4817 $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
4818
4819 // Send the agent's custom farewell message if provided
4820 if (!empty($custom_message)) {
4821 $this->mxchat_save_chat_message($session_id, 'agent', $custom_message);
4822 }
4823
4824 // Confirm in Slack channel
4825 if (!empty($slack_bot_token)) {
4826 wp_remote_post('https://slack.com/api/chat.postMessage', [
4827 'headers' => [
4828 'Content-Type' => 'application/json',
4829 'Authorization' => 'Bearer ' . $slack_bot_token
4830 ],
4831 'body' => json_encode([
4832 'channel' => $channel_id,
4833 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
4834 'mrkdwn' => true
4835 ])
4836 ]);
4837 }
4838
4839 return new WP_REST_Response(['ok' => true]);
4840 }
4841
4842 // Save the agent message
4843 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4844
4845 // Send confirmation back to Slack (only once)
4846 if (!empty($slack_bot_token)) {
4847 // Use a transient to prevent duplicate confirmations
4848 $confirm_key = 'mxchat_confirm_' . $message_key;
4849 if (!get_transient($confirm_key)) {
4850 wp_remote_post('https://slack.com/api/chat.postMessage', [
4851 'headers' => [
4852 'Content-Type' => 'application/json',
4853 'Authorization' => 'Bearer ' . $slack_bot_token
4854 ],
4855 'body' => json_encode([
4856 'channel' => $channel_id,
4857 'text' => "✅ _Message sent to user_",
4858 'thread_ts' => $event['ts'] // Reply in thread
4859 ])
4860 ]);
4861 // Set transient to prevent duplicate confirmations
4862 set_transient($confirm_key, true, 300); // 5 minutes
4863 }
4864 }
4865 }
4866 }
4867
4868 return new WP_REST_Response(['ok' => true]);
4869 }
4870
4871 // For the word upload handler
4872 public function mxchat_handle_word_upload() {
4873 // Delegate to word handler
4874 $this->word_handler->mxchat_handle_word_upload();
4875 }
4876
4877 // For the word removal handler
4878 public function mxchat_handle_word_remove() {
4879 // Delegate to word handler
4880 $this->word_handler->mxchat_handle_word_remove();
4881 }
4882
4883 // For the word status check
4884 public function mxchat_check_word_status() {
4885 // Delegate to word handler
4886 $this->word_handler->mxchat_check_word_status();
4887 }
4888
4889
4890 private function mxchat_get_user_identifier() {
4891 return MxChat_User::mxchat_get_user_identifier();
4892 }
4893
4894 private function mxchat_generate_embedding($text, $api_key) {
4895 try {
4896 // Get options and selected model
4897 $options = get_option('mxchat_options');
4898 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4899
4900 // Opt-in: route embeddings through the Custom (OpenAI-compatible) provider.
4901 // Off by default so existing sites see byte-identical behavior.
4902 if (!empty($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4903 return $this->mxchat_generate_embedding_custom($text);
4904 }
4905
4906 // Determine endpoint and API key based on model
4907 if (strpos($selected_model, 'voyage') === 0) {
4908 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4909 $api_key = $options['voyage_api_key'] ?? '';
4910
4911 // Check if Voyage API key is missing
4912 if (empty($api_key)) {
4913 //error_log('Voyage API key is missing');
4914 return [
4915 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4916 'error_code' => 'missing_voyage_api_key'
4917 ];
4918 }
4919 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4920 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4921 $api_key = $options['gemini_api_key'] ?? '';
4922
4923 // Check if Gemini API key is missing
4924 if (empty($api_key)) {
4925 //error_log('Gemini API key is missing');
4926 return [
4927 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4928 'error_code' => 'missing_gemini_api_key'
4929 ];
4930 }
4931 } else {
4932 $endpoint = 'https://api.openai.com/v1/embeddings';
4933 // Use the passed API key for OpenAI
4934
4935 // Check if OpenAI API key is missing
4936 if (empty($api_key)) {
4937 //error_log('OpenAI API key is missing');
4938 return [
4939 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4940 'error_code' => 'missing_openai_api_key'
4941 ];
4942 }
4943 }
4944
4945 // Check if text is empty
4946 if (empty($text)) {
4947 //error_log('Empty text provided for embedding generation');
4948 return [
4949 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4950 'error_code' => 'empty_embedding_text'
4951 ];
4952 }
4953
4954 // Prepare request body based on provider
4955 if (strpos($selected_model, 'gemini-embedding') === 0) {
4956 // Gemini API format
4957 $request_body = [
4958 'model' => 'models/' . $selected_model,
4959 'content' => [
4960 'parts' => [
4961 ['text' => $text]
4962 ]
4963 ],
4964 'outputDimensionality' => 1536
4965 ];
4966
4967 // Prepare headers for Gemini (API key as query parameter)
4968 $endpoint .= '?key=' . $api_key;
4969 $headers = [
4970 'Content-Type' => 'application/json'
4971 ];
4972 } else {
4973 // OpenAI/Voyage API format
4974 $request_body = [
4975 'input' => $text,
4976 'model' => $selected_model
4977 ];
4978
4979 // Add output_dimension for voyage-3-large
4980 if ($selected_model === 'voyage-3-large') {
4981 $request_body['output_dimension'] = 2048;
4982 }
4983
4984 // Prepare headers for OpenAI/Voyage
4985 $headers = [
4986 'Content-Type' => 'application/json',
4987 'Authorization' => 'Bearer ' . $api_key
4988 ];
4989 }
4990
4991 // Prepare request arguments
4992 $args = [
4993 'body' => wp_json_encode($request_body),
4994 'headers' => $headers,
4995 'timeout' => 60,
4996 'redirection' => 5,
4997 'blocking' => true,
4998 'httpversion' => '1.0',
4999 'sslverify' => true,
5000 ];
5001
5002 // Make the request
5003 $response = wp_remote_post($endpoint, $args);
5004
5005 // Handle WordPress errors
5006 if (is_wp_error($response)) {
5007 $error_message = $response->get_error_message();
5008 //error_log('Embedding Generation Error: ' . $error_message);
5009 return [
5010 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
5011 'error_code' => 'embedding_connection_error'
5012 ];
5013 }
5014
5015 // Check HTTP status code
5016 $status_code = wp_remote_retrieve_response_code($response);
5017 if ($status_code !== 200) {
5018 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5019
5020 $error_message = isset($response_body['error']['message'])
5021 ? $response_body['error']['message']
5022 : 'HTTP Error ' . $status_code;
5023
5024 $error_type = isset($response_body['error']['type'])
5025 ? $response_body['error']['type']
5026 : 'unknown';
5027
5028 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
5029
5030 // Handle specific error types
5031 switch ($error_type) {
5032 case 'invalid_request_error':
5033 if (strpos($error_message, 'API key') !== false) {
5034 return [
5035 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
5036 'error_code' => 'embedding_invalid_api_key'
5037 ];
5038 }
5039 break;
5040
5041 case 'authentication_error':
5042 return [
5043 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
5044 'error_code' => 'embedding_auth_error'
5045 ];
5046
5047 case 'rate_limit_exceeded':
5048 return [
5049 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
5050 'error_code' => 'embedding_rate_limit'
5051 ];
5052
5053 case 'quota_exceeded':
5054 return [
5055 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
5056 'error_code' => 'embedding_quota_exceeded'
5057 ];
5058 }
5059
5060 // Generic error fallback
5061 return [
5062 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
5063 'error_code' => 'embedding_api_error',
5064 'status_code' => $status_code
5065 ];
5066 }
5067
5068 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5069
5070 // Handle different response formats based on provider
5071 if (strpos($selected_model, 'gemini-embedding') === 0) {
5072 // Gemini API response format
5073 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
5074 return $response_body['embedding']['values'];
5075 } else {
5076 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
5077 return [
5078 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
5079 'error_code' => 'invalid_gemini_embedding_response'
5080 ];
5081 }
5082 } else {
5083 // OpenAI/Voyage API response format
5084 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
5085 return $response_body['data'][0]['embedding'];
5086 } else {
5087 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
5088 return [
5089 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
5090 'error_code' => 'invalid_embedding_response'
5091 ];
5092 }
5093 }
5094 } catch (Exception $e) {
5095 //error_log('Embedding Exception: ' . $e->getMessage());
5096 return [
5097 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
5098 'error_code' => 'embedding_exception'
5099 ];
5100 }
5101 }
5102
5103
5104 /**
5105 * Generate embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
5106 * Only called when the opt-in 'custom_provider_for_embeddings' setting is on.
5107 * Returns a numeric array (the embedding vector) on success, or ['error','error_code'] on failure.
5108 */
5109 private function mxchat_generate_embedding_custom($text) {
5110 if (empty($text)) {
5111 return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
5112 }
5113 $cfg = $this->mxchat_resolve_custom_provider();
5114 if (empty($cfg['base_url'])) {
5115 return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
5116 }
5117
5118 $options = get_option('mxchat_options');
5119 $embed_url = $cfg['base_url'] . '/embeddings';
5120 if (!empty($cfg['api_version'])) {
5121 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
5122 }
5123 $model = isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== ''
5124 ? trim((string) $options['custom_provider_embedding_model'])
5125 : $cfg['model'];
5126
5127 $response = wp_remote_post($embed_url, [
5128 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
5129 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
5130 'timeout' => 60,
5131 ]);
5132 if (is_wp_error($response)) {
5133 return [
5134 'error' => esc_html__('Connection error when generating embeddings (custom provider): ', 'mxchat') . esc_html($response->get_error_message()),
5135 'error_code' => 'embedding_custom_connection_error',
5136 ];
5137 }
5138 $status = wp_remote_retrieve_response_code($response);
5139 $body = json_decode(wp_remote_retrieve_body($response), true);
5140 if ($status !== 200) {
5141 $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
5142 return [
5143 'error' => esc_html__('Custom embedding endpoint error: ', 'mxchat') . esc_html($msg),
5144 'error_code' => 'embedding_custom_api_error',
5145 'status_code' => $status,
5146 ];
5147 }
5148 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
5149 return $body['data'][0]['embedding'];
5150 }
5151 return [
5152 'error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'),
5153 'error_code' => 'embedding_custom_invalid_response',
5154 ];
5155 }
5156
5157 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
5158 //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
5159
5160 // Check for OpenAI Vector Store first (takes priority when enabled)
5161 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5162
5163 if ($bot_vectorstore_config['use_vectorstore']) {
5164 // Get current model to verify it's an OpenAI model
5165 $bot_options = $this->get_bot_options($bot_id);
5166 $mxchat_options = get_option('mxchat_options', array());
5167 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5168 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5169
5170 if ($this->is_openai_chat_model($selected_model)) {
5171 //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
5172 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
5173 } else {
5174 //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
5175 }
5176 }
5177
5178 // Get bot-specific Pinecone configuration
5179 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
5180
5181 // Debug: Log the Pinecone configuration
5182 //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
5183 //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
5184 //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
5185 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
5186 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
5187
5188 // Determine whether to use Pinecone based on bot configuration
5189 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
5190
5191 //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
5192
5193 if ($use_pinecone) {
5194 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
5195 } else {
5196 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
5197 }
5198 }
5199
5200 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
5201 global $wpdb;
5202 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5203 // Initialize similarity analysis storage
5204 $this->last_similarity_analysis = [
5205 'knowledge_base_type' => 'WordPress Database',
5206 'bot_id' => $bot_id,
5207 'top_matches' => [],
5208 'threshold_used' => 0,
5209 'total_checked' => 0
5210 ];
5211
5212 // NEW: Initialize valid URLs array
5213 $valid_urls = [];
5214
5215 // Get bot-specific options for similarity threshold
5216 $bot_options = $this->get_bot_options($bot_id);
5217 $current_options = !empty($bot_options) ? $bot_options : $this->options;
5218
5219 // Get knowledge manager instance for role checking
5220 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5221
5222 // Get base similarity threshold from bot options or default options
5223 $similarity_threshold = isset($current_options['similarity_threshold'])
5224 ? ((int) $current_options['similarity_threshold']) / 100
5225 : 0.35;
5226 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5227
5228 // Precompute bot_filter once, outside the streaming loop
5229 $bot_filter = '';
5230 if ($bot_id !== 'default') {
5231 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
5232 if ($column_exists) {
5233 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
5234 }
5235 }
5236
5237 // ===== STREAMING TOP-K PASS =====
5238 // Stream rows in small batches, compute cosine similarity per row, and keep only:
5239 // - top 10 by raw similarity (for the testing/debug display panel)
5240 // - candidates above threshold with access (capped) for context assembly
5241 // This bounds peak memory regardless of knowledge base size and avoids loading
5242 // article_content for every row. article_content is fetched in Phase 2 for winners only.
5243 $batch_size = 250;
5244 $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
5245 $top_display = [];
5246 $candidates = [];
5247 $total_checked = 0;
5248 $offset = 0;
5249
5250 do {
5251 $batch = $wpdb->get_results($wpdb->prepare(
5252 "SELECT id, embedding_vector, source_url, role_restriction
5253 FROM {$system_prompt_table}
5254 WHERE 1=1 {$bot_filter}
5255 LIMIT %d OFFSET %d",
5256 $batch_size,
5257 $offset
5258 ));
5259
5260 if (empty($batch)) {
5261 break;
5262 }
5263
5264 foreach ($batch as $row) {
5265 $database_embedding = $row->embedding_vector
5266 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
5267 : null;
5268
5269 if (!is_array($database_embedding) || !is_array($user_embedding)) {
5270 unset($database_embedding);
5271 continue;
5272 }
5273
5274 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
5275 unset($database_embedding);
5276
5277 $role_restriction = $row->role_restriction ?? 'public';
5278 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5279 $source_url = $row->source_url ?? '';
5280
5281 // Maintain top 10 display buffer (insert-if-beats-worst)
5282 if (count($top_display) < 10) {
5283 $top_display[] = [
5284 'id' => $row->id,
5285 'similarity' => $similarity,
5286 'source_url' => $source_url,
5287 'role_restriction' => $role_restriction,
5288 'has_access' => $has_access,
5289 ];
5290 usort($top_display, function ($a, $b) {
5291 return $b['similarity'] <=> $a['similarity'];
5292 });
5293 } elseif ($similarity > $top_display[9]['similarity']) {
5294 $top_display[9] = [
5295 'id' => $row->id,
5296 'similarity' => $similarity,
5297 'source_url' => $source_url,
5298 'role_restriction' => $role_restriction,
5299 'has_access' => $has_access,
5300 ];
5301 usort($top_display, function ($a, $b) {
5302 return $b['similarity'] <=> $a['similarity'];
5303 });
5304 }
5305
5306 // Track candidates for context assembly (above threshold + has access)
5307 if ($similarity >= $similarity_threshold && $has_access) {
5308 $candidates[] = [
5309 'id' => $row->id,
5310 'similarity' => $similarity,
5311 'source_url' => $source_url,
5312 ];
5313 }
5314
5315 $total_checked++;
5316 }
5317
5318 unset($batch);
5319
5320 // Trim candidates periodically to cap memory during long scans
5321 if (count($candidates) > $max_candidates) {
5322 usort($candidates, function ($a, $b) {
5323 return $b['similarity'] <=> $a['similarity'];
5324 });
5325 $candidates = array_slice($candidates, 0, $max_candidates);
5326 }
5327
5328 $offset += $batch_size;
5329 } while (true);
5330
5331 if ($total_checked === 0) {
5332 $this->current_valid_urls = [];
5333 return '';
5334 }
5335
5336 // Final candidates sort (best first)
5337 if (count($candidates) > 1) {
5338 usort($candidates, function ($a, $b) {
5339 return $b['similarity'] <=> $a['similarity'];
5340 });
5341 }
5342
5343 // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
5344 // Gather unique IDs we actually need (top_display + candidates) and pull
5345 // article_content in bounded IN() batches. This avoids loading content for
5346 // every row during the similarity scan.
5347 $needed_ids = [];
5348 foreach ($top_display as $item) {
5349 $needed_ids[$item['id']] = true;
5350 }
5351 foreach ($candidates as $item) {
5352 $needed_ids[$item['id']] = true;
5353 }
5354 $needed_ids = array_keys($needed_ids);
5355
5356 $content_map = [];
5357 if (!empty($needed_ids)) {
5358 foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
5359 $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
5360 $rows = $wpdb->get_results($wpdb->prepare(
5361 "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
5362 ...$chunk_ids
5363 ));
5364 foreach ($rows as $r) {
5365 $content_map[$r->id] = $r->article_content;
5366 }
5367 unset($rows);
5368 }
5369 }
5370
5371 // Build the all_similarities display array from the top 10
5372 $all_similarities = [];
5373 foreach ($top_display as $item) {
5374 $article_content_for_parse = $content_map[$item['id']] ?? '';
5375 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
5376 $is_chunk = $parsed_for_display['is_chunked'];
5377 $chunk_meta = $parsed_for_display['metadata'];
5378
5379 if (!empty($item['source_url']) && $item['source_url'] !== '#') {
5380 $source_display = $item['source_url'];
5381 } else {
5382 $content_preview = strip_tags($article_content_for_parse);
5383 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5384 $source_display = substr(trim($content_preview), 0, 50) . '...';
5385 }
5386
5387 $all_similarities[] = [
5388 'document_id' => $item['id'],
5389 'similarity' => $item['similarity'],
5390 'similarity_percentage' => round($item['similarity'] * 100, 2),
5391 'above_threshold' => $item['similarity'] >= $similarity_threshold,
5392 'source_display' => $source_display,
5393 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
5394 'used_for_context' => false,
5395 'role_restriction' => $item['role_restriction'],
5396 'has_access' => $item['has_access'],
5397 'filtered_out' => !$item['has_access'],
5398 'is_chunk' => $is_chunk,
5399 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
5400 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
5401 ];
5402 }
5403
5404 // Build url_groups from candidates for chunk reassembly
5405 $url_groups = array();
5406 foreach ($candidates as $cand) {
5407 $article_content = $content_map[$cand['id']] ?? '';
5408 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
5409 $is_chunked = $parsed['is_chunked'];
5410 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5411 $text_content = $parsed['text'];
5412
5413 $source_url = $cand['source_url'];
5414 $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
5415
5416 if (!isset($url_groups[$group_key])) {
5417 $url_groups[$group_key] = array(
5418 'source_url' => $source_url,
5419 'best_score' => 0,
5420 'is_chunked' => $is_chunked,
5421 'chunks' => array(),
5422 'single_text' => '',
5423 'single_id' => null
5424 );
5425 }
5426
5427 if ($cand['similarity'] > $url_groups[$group_key]['best_score']) {
5428 $url_groups[$group_key]['best_score'] = $cand['similarity'];
5429 }
5430
5431 if ($is_chunked) {
5432 $url_groups[$group_key]['is_chunked'] = true;
5433 $url_groups[$group_key]['chunks'][] = array(
5434 'id' => $cand['id'],
5435 'score' => $cand['similarity'],
5436 'chunk_index' => $chunk_index,
5437 'text' => $text_content
5438 );
5439 } else {
5440 $url_groups[$group_key]['single_text'] = $text_content;
5441 $url_groups[$group_key]['single_id'] = $cand['id'];
5442 }
5443 }
5444
5445 // Sort ALL similarities for testing display (highest first)
5446 usort($all_similarities, function ($a, $b) {
5447 return $b['similarity'] <=> $a['similarity'];
5448 });
5449
5450 // Sort URL groups by best score (highest first)
5451 uasort($url_groups, function($a, $b) {
5452 return $b['best_score'] <=> $a['best_score'];
5453 });
5454
5455 // Get RAG sources limit from options (default 6, min 3, max 10)
5456 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5457 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5458 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5459
5460 // Take top N unique URLs based on user setting
5461 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5462
5463 // Track which document IDs are used for context
5464 $used_document_ids = [];
5465 foreach ($top_urls as $group) {
5466 if ($group['is_chunked']) {
5467 foreach ($group['chunks'] as $chunk) {
5468 $used_document_ids[] = $chunk['id'];
5469 }
5470 } elseif ($group['single_id']) {
5471 $used_document_ids[] = $group['single_id'];
5472 }
5473 }
5474
5475 // Update the all_similarities array to mark which were actually used
5476 foreach ($all_similarities as &$similarity_item) {
5477 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
5478 }
5479
5480 // Store top 10 for testing panel
5481 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
5482 $this->last_similarity_analysis['total_checked'] = $total_checked;
5483
5484 // Initialize final content
5485 $content = '';
5486 $matches_used = 0;
5487 $total_chunks_used = 0;
5488 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5489 if ($max_total_chunks < 8) $max_total_chunks = 8;
5490 if ($max_total_chunks > 20) $max_total_chunks = 20;
5491 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5492
5493 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5494 // Use fresh options to ensure we get the latest setting value
5495 $fresh_options = get_option('mxchat_options', []);
5496 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5497
5498 // Build content from top sources
5499 foreach ($top_urls as $group_key => $group) {
5500 $source_url = $group['source_url']; // Use actual source_url, not the group key
5501
5502 // Stop if we've hit the total chunk limit
5503 if ($total_chunks_used >= $max_total_chunks) {
5504 break;
5505 }
5506
5507 $full_text = '';
5508 $chunks_in_this_source = 1; // Default for non-chunked content
5509
5510 if ($group['is_chunked']) {
5511 // Calculate how many chunks we can still use (respect both total and per-source caps)
5512 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5513
5514 // Fetch chunks for this URL with limit
5515 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
5516
5517 // If fetching all chunks fails, fall back to matched chunks
5518 if (empty($full_text)) {
5519 // Sort matched chunks by index and concatenate
5520 usort($group['chunks'], function($a, $b) {
5521 return $a['chunk_index'] <=> $b['chunk_index'];
5522 });
5523
5524 $chunk_texts = array();
5525 $chunks_in_this_source = 0;
5526 foreach ($group['chunks'] as $chunk) {
5527 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5528 break;
5529 }
5530 $chunk_texts[] = $chunk['text'];
5531 $chunks_in_this_source++;
5532 }
5533 $full_text = implode("\n\n", $chunk_texts);
5534 }
5535 } else {
5536 $full_text = $group['single_text'];
5537 $chunks_in_this_source = 1;
5538 }
5539
5540 if (!empty($full_text)) {
5541 // Strip URLs from content if citation links are disabled
5542 if (!$citation_links_enabled) {
5543 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5544 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5545 }
5546
5547 // Use numbered reference for URL-based entries, plain info label for manual entries
5548 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
5549 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
5550 $matches_used++;
5551 $content .= "## Reference " . $matches_used . " ##\n";
5552 $content .= $full_text . "\n\n";
5553
5554 // Only include citation URLs if citation links are enabled
5555 if ($citation_links_enabled) {
5556 $valid_urls[] = $source_url;
5557 $content .= "URL: " . $source_url . "\n\n";
5558 }
5559 } else {
5560 // Manual entry — no reference number, no citation
5561 $content .= "## Information ##\n";
5562 $content .= $full_text . "\n\n";
5563 }
5564
5565 // Extract any URLs from the text content itself (only if citation links enabled)
5566 if ($citation_links_enabled) {
5567 preg_match_all(
5568 '#\bhttps?://[^\s<>"\']+#i',
5569 $full_text,
5570 $content_urls
5571 );
5572 if (!empty($content_urls[0])) {
5573 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5574 }
5575 }
5576
5577 $total_chunks_used += $chunks_in_this_source;
5578 }
5579 }
5580
5581 // NEW: Store unique valid URLs for validation
5582 $this->current_valid_urls = array_unique($valid_urls);
5583
5584 // Store sources and chunks counts for testing/transcript display
5585 $this->last_similarity_analysis['sources_used'] = $matches_used;
5586 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
5587
5588 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
5589 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
5590
5591 // Add response guidelines
5592 if (empty($top_urls)) {
5593 $content = "No reference information was found for this query.\n\n";
5594 } else {
5595 // Build response guidelines based on citation links setting
5596 $content .= "\n## Response Guidelines ##\n" .
5597 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5598 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5599 "If you don't have specific information or are uncertain about any details, it's always " .
5600 "better to honestly say you don't know rather than making up or guessing at answers. " .
5601 "When information is incomplete, let them know you are unsure.\n\n";
5602
5603 // Only add hyperlink instructions if citation links are enabled
5604 if ($citation_links_enabled) {
5605 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5606 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
5607 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
5608 } else {
5609 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5610 "Simply provide helpful answers based on the reference information without citing sources.";
5611 }
5612 }
5613
5614 return trim($content);
5615 }
5616
5617 /**
5618 * Fetch and reassemble chunks for a URL from WordPress database
5619 *
5620 * @param string $source_url The source URL to fetch chunks for
5621 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
5622 * @param int &$chunk_count Reference to store the actual number of chunks returned
5623 * @return string Reassembled content from chunks
5624 */
5625 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
5626 global $wpdb;
5627 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
5628
5629 // Fetch all rows with this source_url
5630 $rows = $wpdb->get_results($wpdb->prepare(
5631 "SELECT article_content FROM {$table}
5632 WHERE source_url = %s
5633 ORDER BY id ASC",
5634 $source_url
5635 ));
5636
5637 if (empty($rows)) {
5638 $chunk_count = 0;
5639 return '';
5640 }
5641
5642 // Parse and sort chunks by index
5643 $chunks = array();
5644 foreach ($rows as $row) {
5645 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
5646
5647 if ($parsed['is_chunked']) {
5648 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
5649 $chunks[$chunk_index] = $parsed['text'];
5650 } else {
5651 // Non-chunked content - just return it
5652 $chunks[] = $parsed['text'];
5653 }
5654 }
5655
5656 // Sort by chunk index
5657 ksort($chunks);
5658
5659 // Apply chunk limit if specified
5660 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5661 $chunks = array_slice($chunks, 0, $max_chunks, true);
5662 }
5663
5664 // Store actual chunk count
5665 $chunk_count = count($chunks);
5666
5667 // Reassemble content
5668 return implode("\n\n", $chunks);
5669 }
5670
5671 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
5672 global $wpdb;
5673
5674 //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
5675 //error_log(" - bot_id: " . $bot_id);
5676 //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
5677 //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
5678
5679 // Use bot-specific config or fall back to default
5680 if ($bot_config === null) {
5681 $bot_config = $this->get_bot_pinecone_config($bot_id);
5682 }
5683
5684 $api_key = $bot_config['api_key'] ?? '';
5685 $host = $bot_config['host'] ?? '';
5686 $namespace = $bot_config['namespace'] ?? '';
5687
5688 //error_log("MXCHAT DEBUG: Pinecone query parameters:");
5689 //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
5690 //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
5691 //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
5692
5693 // Initialize similarity analysis storage
5694 $this->last_similarity_analysis = [
5695 'knowledge_base_type' => 'Pinecone',
5696 'bot_id' => $bot_id,
5697 'namespace' => $namespace,
5698 'top_matches' => [],
5699 'threshold_used' => 0,
5700 'total_checked' => 0
5701 ];
5702
5703 // NEW: Initialize valid URLs array
5704 $valid_urls = [];
5705
5706 if (empty($host) || empty($api_key)) {
5707 //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
5708 //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
5709 //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
5710 // Store empty array for valid URLs since we can't proceed
5711 $this->current_valid_urls = [];
5712 return '';
5713 }
5714
5715 // Get knowledge manager instance for role checking
5716 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
5717
5718 // Get the similarity threshold from the bot options or main options
5719 $bot_options = $this->get_bot_options($bot_id);
5720 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
5721
5722 $similarity_threshold = isset($current_options['similarity_threshold'])
5723 ? ((int) $current_options['similarity_threshold']) / 100
5724 : 0.35;
5725
5726 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
5727
5728 // Prepare the query request for Pinecone
5729 $api_endpoint = "https://{$host}/query";
5730
5731 $request_body = array(
5732 'vector' => $user_embedding,
5733 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
5734 'includeMetadata' => true,
5735 'includeValues' => true
5736 );
5737
5738 // Add namespace if specified for this bot
5739 if (!empty($namespace)) {
5740 $request_body['namespace'] = $namespace;
5741 }
5742
5743 //error_log("MXCHAT DEBUG: About to call Pinecone API");
5744 //error_log(" - Endpoint: " . $api_endpoint);
5745 //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
5746
5747 $response = wp_remote_post($api_endpoint, array(
5748 'headers' => array(
5749 'Api-Key' => $api_key,
5750 'accept' => 'application/json',
5751 'content-type' => 'application/json'
5752 ),
5753 'body' => wp_json_encode($request_body),
5754 'timeout' => 30
5755 ));
5756
5757 if (is_wp_error($response)) {
5758 //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
5759 // Store empty array for valid URLs
5760 $this->current_valid_urls = [];
5761 return '';
5762 }
5763
5764 $response_code = wp_remote_retrieve_response_code($response);
5765 //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
5766
5767 if ($response_code !== 200) {
5768 $response_body = wp_remote_retrieve_body($response);
5769 //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
5770 // Store empty array for valid URLs
5771 $this->current_valid_urls = [];
5772 return '';
5773 }
5774
5775 // ADD DETAILED DEBUG SECTION HERE
5776 $response_body = wp_remote_retrieve_body($response);
5777 //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
5778
5779 $results = json_decode($response_body, true);
5780
5781 if (json_last_error() !== JSON_ERROR_NONE) {
5782 //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
5783 //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
5784 // Store empty array for valid URLs
5785 $this->current_valid_urls = [];
5786 return '';
5787 }
5788
5789 //error_log("MXCHAT DEBUG: Pinecone response structure:");
5790 //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
5791 //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
5792
5793 if (empty($results['matches'])) {
5794 //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
5795 //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
5796 // Store empty array for valid URLs
5797 $this->current_valid_urls = [];
5798 return '';
5799 }
5800
5801 //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
5802
5803 // Log first match details for debugging
5804 if (!empty($results['matches'][0])) {
5805 $first_match = $results['matches'][0];
5806 //error_log("MXCHAT DEBUG: First match details:");
5807 //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5808 //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5809 if (isset($first_match['metadata'])) {
5810 //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5811 }
5812 }
5813
5814 // Initialize the final content
5815 $content = '';
5816 $matches_used = 0;
5817 $matches_used_for_context = [];
5818 $total_chunks_used = 0;
5819 $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
5820 if ($max_total_chunks < 8) $max_total_chunks = 8;
5821 if ($max_total_chunks > 20) $max_total_chunks = 20;
5822 $max_chunks_per_source = 5; // Cap per individual source to limit token usage
5823
5824 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5825 // Use fresh options to ensure we get the latest setting value
5826 $fresh_options = get_option('mxchat_options', []);
5827 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5828
5829 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5830 $url_groups = array();
5831
5832 foreach ($results['matches'] as $index => $match) {
5833 // Skip if similarity is below threshold
5834 if ($match['score'] < $similarity_threshold) {
5835 continue;
5836 }
5837
5838 $metadata = $match['metadata'] ?? array();
5839 $source_url = $metadata['source_url'] ?? '';
5840 $match_id = $match['id'] ?? '';
5841
5842 // LAZY ROLE CHECK: Only check role for content we're actually considering
5843 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5844 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5845
5846 // Skip if user doesn't have access
5847 if (!$has_access) {
5848 continue;
5849 }
5850
5851 // Use a unique key for manual entries without a source URL
5852 $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
5853
5854 // Group by source URL (or unique key for manual entries)
5855 if (!isset($url_groups[$group_key])) {
5856 $url_groups[$group_key] = array(
5857 'source_url' => $source_url,
5858 'best_score' => 0,
5859 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5860 'chunks' => array(),
5861 'single_text' => ''
5862 );
5863 }
5864
5865 // Track best score for this group
5866 if ($match['score'] > $url_groups[$group_key]['best_score']) {
5867 $url_groups[$group_key]['best_score'] = $match['score'];
5868 }
5869
5870 // Store chunk info or single text
5871 if ($url_groups[$group_key]['is_chunked']) {
5872 $url_groups[$group_key]['chunks'][] = array(
5873 'id' => $match_id,
5874 'score' => $match['score'],
5875 'chunk_index' => $metadata['chunk_index'] ?? 0,
5876 'text' => $metadata['text'] ?? ''
5877 );
5878 } else {
5879 // Non-chunked content - just store the text
5880 $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
5881 $url_groups[$group_key]['single_id'] = $match_id;
5882 }
5883 }
5884
5885 // Sort URL groups by best score (highest first)
5886 uasort($url_groups, function($a, $b) {
5887 return $b['best_score'] <=> $a['best_score'];
5888 });
5889
5890 // Get RAG sources limit from options (default 6, min 3, max 10)
5891 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
5892 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5893 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5894
5895 // Take top N unique URLs based on user setting
5896 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5897
5898 // Track which match IDs are actually used for context
5899 foreach ($top_urls as $group) {
5900 if ($group['is_chunked']) {
5901 foreach ($group['chunks'] as $chunk) {
5902 $matches_used_for_context[] = $chunk['id'];
5903 }
5904 } elseif (!empty($group['single_id'])) {
5905 $matches_used_for_context[] = $group['single_id'];
5906 }
5907 }
5908
5909 // Build content from top sources
5910 foreach ($top_urls as $group_key => $group) {
5911 $source_url = $group['source_url']; // Use actual source_url, not the group key
5912
5913 // Stop if we've hit the total chunk limit
5914 if ($total_chunks_used >= $max_total_chunks) {
5915 break;
5916 }
5917
5918 $full_text = '';
5919 $chunks_in_this_source = 1; // Default for non-chunked content
5920
5921 if ($group['is_chunked']) {
5922 // Calculate how many chunks we can still use (respect both total and per-source caps)
5923 $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
5924
5925 // Fetch chunks for this URL with limit
5926 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5927
5928 // If fetching all chunks fails, fall back to matched chunks
5929 if (empty($full_text)) {
5930 // Sort matched chunks by index and concatenate
5931 usort($group['chunks'], function($a, $b) {
5932 return $a['chunk_index'] <=> $b['chunk_index'];
5933 });
5934
5935 $chunk_texts = array();
5936 $chunks_in_this_source = 0;
5937 foreach ($group['chunks'] as $chunk) {
5938 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5939 break;
5940 }
5941 $chunk_texts[] = $chunk['text'];
5942 $chunks_in_this_source++;
5943 }
5944 $full_text = implode("\n\n", $chunk_texts);
5945 }
5946 } else {
5947 $full_text = $group['single_text'];
5948 $chunks_in_this_source = 1;
5949 }
5950
5951 if (!empty($full_text)) {
5952 // Strip URLs from content if citation links are disabled
5953 if (!$citation_links_enabled) {
5954 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5955 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5956 }
5957
5958 // Use numbered reference for URL-based entries, plain info label for manual entries
5959 // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
5960 if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
5961 $matches_used++;
5962 $content .= "## Reference " . $matches_used . " ##\n";
5963 $content .= $full_text . "\n\n";
5964
5965 // Only include citation URLs if citation links are enabled
5966 if ($citation_links_enabled) {
5967 $valid_urls[] = $source_url;
5968 $content .= "URL: " . $source_url . "\n\n";
5969 }
5970 } else {
5971 // Manual entry — no reference number, no citation
5972 $content .= "## Information ##\n";
5973 $content .= $full_text . "\n\n";
5974 }
5975
5976 // Extract any URLs from the text content itself (only if citation links enabled)
5977 if ($citation_links_enabled) {
5978 preg_match_all(
5979 '#\bhttps?://[^\s<>"\']+#i',
5980 $full_text,
5981 $content_urls
5982 );
5983 if (!empty($content_urls[0])) {
5984 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5985 }
5986 }
5987
5988 $total_chunks_used += $chunks_in_this_source;
5989 }
5990 }
5991
5992 // Process ALL matches for testing data (top 10) - with role checking for testing display
5993 $all_matches = [];
5994 foreach ($results['matches'] as $index => $match) {
5995 if ($index >= 10) break; // Limit to top 10 for testing
5996
5997 $match_id = $match['id'] ?? '';
5998
5999 // Check role access for testing display (use cache if available)
6000 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
6001 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6002
6003 $source_display = '';
6004 if (!empty($match['metadata']['source_url'])) {
6005 $source_display = $match['metadata']['source_url'];
6006 } else {
6007 $content_preview = strip_tags($match['metadata']['text'] ?? '');
6008 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6009 $source_display = substr(trim($content_preview), 0, 50) . '...';
6010 }
6011
6012 $match_id_for_display = $match['id'] ?? $index;
6013
6014 // Check for chunk metadata in Pinecone
6015 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
6016 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
6017 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
6018
6019 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
6020 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
6021 $is_chunk = true;
6022 }
6023
6024 $all_matches[] = [
6025 'document_id' => $match_id_for_display,
6026 'similarity' => $match['score'],
6027 'similarity_percentage' => round($match['score'] * 100, 2),
6028 'above_threshold' => $match['score'] >= $similarity_threshold,
6029 'source_display' => $source_display,
6030 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
6031 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
6032 'role_restriction' => $role_restriction,
6033 'has_access' => $has_access,
6034 'filtered_out' => !$has_access,
6035 'is_chunk' => $is_chunk,
6036 'chunk_index' => $chunk_index,
6037 'total_chunks' => $total_chunks
6038 ];
6039 }
6040
6041 // Store for testing panel
6042 $this->last_similarity_analysis['top_matches'] = $all_matches;
6043 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
6044 $this->last_similarity_analysis['sources_used'] = $matches_used;
6045 $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6046
6047 // NEW: Store unique valid URLs for validation
6048 $this->current_valid_urls = array_unique($valid_urls);
6049
6050 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6051 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6052
6053 // Add response guidelines
6054 if ($matches_used === 0) {
6055 $content = "No reference information was found for this query.\n\n";
6056 } else {
6057 // Build response guidelines based on citation links setting
6058 $content .= "\n## Response Guidelines ##\n" .
6059 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6060 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6061 "If you don't have specific information or are uncertain about any details, it's always " .
6062 "better to honestly say you don't know rather than making up or guessing at answers. " .
6063 "When information is incomplete, let them know you are unsure.\n\n";
6064
6065 // Only add hyperlink instructions if citation links are enabled
6066 if ($citation_links_enabled) {
6067 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6068 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6069 "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6070 } else {
6071 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6072 "Simply provide helpful answers based on the reference information without citing sources.";
6073 }
6074 }
6075
6076 return trim($content);
6077 }
6078
6079 /**
6080 * Get role restriction for a single vector (with caching)
6081 */
6082 private function get_single_vector_role($vector_id, $metadata = array()) {
6083 global $wpdb;
6084
6085 if (empty($vector_id)) {
6086 return 'public';
6087 }
6088
6089 // Check cache first
6090 $cache_key = 'mxchat_vector_role_' . $vector_id;
6091 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
6092
6093 if ($cached_role !== false) {
6094 return $cached_role;
6095 }
6096
6097 $role_restriction = 'public';
6098
6099 // First try Pinecone metadata
6100 if (!empty($metadata['role_restriction'])) {
6101 $role_restriction = $metadata['role_restriction'];
6102 } else {
6103 // Check WordPress table for user-modified roles
6104 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
6105 $stored_role = $wpdb->get_var($wpdb->prepare(
6106 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
6107 $vector_id
6108 ));
6109
6110 if ($stored_role) {
6111 $role_restriction = $stored_role;
6112 }
6113 }
6114
6115 // Cache individual role for 1 hour
6116 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
6117
6118 return $role_restriction;
6119 }
6120
6121 /**
6122 * Fetch and reassemble all chunks for a URL from Pinecone
6123 *
6124 * @param string $source_url The source URL to fetch chunks for
6125 * @param array $bot_config Bot-specific Pinecone configuration
6126 * @return string Reassembled content from all chunks
6127 */
6128 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
6129 $api_key = $bot_config['api_key'] ?? '';
6130 $host = $bot_config['host'] ?? '';
6131 $namespace = $bot_config['namespace'] ?? '';
6132
6133 if (empty($host) || empty($api_key)) {
6134 $chunk_count = 0;
6135 return '';
6136 }
6137
6138 $base_hash = md5($source_url);
6139
6140 // Use Pinecone list API to find all chunk vectors with this prefix
6141 $list_url = "https://{$host}/vectors/list";
6142
6143 // Limit to max_chunks if specified, otherwise fetch up to 100
6144 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
6145
6146 $list_body = array(
6147 'prefix' => $base_hash . '_chunk_',
6148 'limit' => $fetch_limit
6149 );
6150
6151 if (!empty($namespace)) {
6152 $list_body['namespace'] = $namespace;
6153 }
6154
6155 $list_response = wp_remote_post($list_url, array(
6156 'headers' => array(
6157 'Api-Key' => $api_key,
6158 'accept' => 'application/json',
6159 'content-type' => 'application/json'
6160 ),
6161 'body' => wp_json_encode($list_body),
6162 'timeout' => 30
6163 ));
6164
6165 if (is_wp_error($list_response)) {
6166 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
6167 return '';
6168 }
6169
6170 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
6171
6172 if (empty($list_data['vectors'])) {
6173 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
6174 return '';
6175 }
6176
6177 // Extract vector IDs
6178 $vector_ids = array();
6179 foreach ($list_data['vectors'] as $vector) {
6180 if (isset($vector['id'])) {
6181 $vector_ids[] = $vector['id'];
6182 }
6183 }
6184
6185 if (empty($vector_ids)) {
6186 return '';
6187 }
6188
6189 // Fetch all chunk content
6190 $fetch_url = "https://{$host}/vectors/fetch";
6191
6192 $fetch_body = array(
6193 'ids' => $vector_ids
6194 );
6195
6196 if (!empty($namespace)) {
6197 $fetch_body['namespace'] = $namespace;
6198 }
6199
6200 $fetch_response = wp_remote_post($fetch_url, array(
6201 'headers' => array(
6202 'Api-Key' => $api_key,
6203 'accept' => 'application/json',
6204 'content-type' => 'application/json'
6205 ),
6206 'body' => wp_json_encode($fetch_body),
6207 'timeout' => 30
6208 ));
6209
6210 if (is_wp_error($fetch_response)) {
6211 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
6212 return '';
6213 }
6214
6215 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
6216
6217 if (empty($fetch_data['vectors'])) {
6218 return '';
6219 }
6220
6221 // Sort chunks by index and reassemble
6222 $chunks = array();
6223 foreach ($fetch_data['vectors'] as $id => $vector) {
6224 $metadata = $vector['metadata'] ?? array();
6225 $chunk_index = $metadata['chunk_index'] ?? 0;
6226 $text = $metadata['text'] ?? '';
6227
6228 // Store chunk with its index
6229 $chunks[$chunk_index] = $text;
6230 }
6231
6232 // Sort by chunk index
6233 ksort($chunks);
6234
6235 // Apply chunk limit if specified
6236 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6237 $chunks = array_slice($chunks, 0, $max_chunks, true);
6238 }
6239
6240 // Store actual chunk count
6241 $chunk_count = count($chunks);
6242
6243 // Reassemble content
6244 return implode("\n\n", $chunks);
6245 }
6246
6247 /**
6248 * Search for relevant content using OpenAI Vector Store (File Search)
6249 *
6250 * @param string $user_query The user's query text
6251 * @param string $bot_id The bot ID
6252 * @param array $vectorstore_config Vector Store configuration
6253 * @return string Formatted context string with references
6254 */
6255 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
6256 //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
6257 //error_log(" - bot_id: " . $bot_id);
6258 //error_log(" - user_query length: " . strlen($user_query));
6259
6260 // Get OpenAI API key
6261 $mxchat_options = get_option('mxchat_options', array());
6262 $api_key = $mxchat_options['api_key'] ?? '';
6263
6264 // Reset vectorstore error tracking
6265 $this->last_vectorstore_error = null;
6266
6267 if (empty($api_key)) {
6268 //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
6269 $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
6270 $this->current_valid_urls = [];
6271 return '';
6272 }
6273
6274 // Get Vector Store configuration
6275 if (empty($vectorstore_config)) {
6276 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6277 }
6278
6279 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
6280 $max_results = $vectorstore_config['max_results'] ?? 5;
6281
6282 if (empty($vectorstore_ids_string)) {
6283 //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
6284 $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
6285 $this->current_valid_urls = [];
6286 return '';
6287 }
6288
6289 // Parse Vector Store IDs
6290 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
6291 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
6292
6293 //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
6294 //error_log("MXCHAT DEBUG: Max results: " . $max_results);
6295
6296 // Initialize similarity analysis storage
6297 $this->last_similarity_analysis = [
6298 'knowledge_base_type' => 'OpenAI Vector Store',
6299 'bot_id' => $bot_id,
6300 'vectorstore_ids' => $vectorstore_ids,
6301 'top_matches' => [],
6302 'threshold_used' => 0,
6303 'total_checked' => 0
6304 ];
6305
6306 $valid_urls = [];
6307
6308 // Get the selected model
6309 $bot_options = $this->get_bot_options($bot_id);
6310 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6311 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
6312
6313 // Verify it's an OpenAI model
6314 if (!$this->is_openai_chat_model($selected_model)) {
6315 //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
6316 $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
6317 $this->current_valid_urls = [];
6318 return '';
6319 }
6320
6321 // Use OpenAI Responses API with file_search tool
6322 $request_body = array(
6323 'model' => $selected_model,
6324 'input' => $user_query,
6325 'tools' => array(
6326 array(
6327 'type' => 'file_search',
6328 'vector_store_ids' => $vectorstore_ids,
6329 'max_num_results' => intval($max_results)
6330 )
6331 ),
6332 'include' => array('output[*].file_search_call.search_results')
6333 );
6334
6335 //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
6336 //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
6337 //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
6338 //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
6339 //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
6340 //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
6341
6342 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
6343 'headers' => array(
6344 'Authorization' => 'Bearer ' . $api_key,
6345 'Content-Type' => 'application/json'
6346 ),
6347 'body' => wp_json_encode($request_body),
6348 'timeout' => 60
6349 ));
6350
6351 if (is_wp_error($response)) {
6352 //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
6353 $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
6354 $this->current_valid_urls = [];
6355 return '';
6356 }
6357
6358 $response_code = wp_remote_retrieve_response_code($response);
6359 //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
6360
6361 $response_body = wp_remote_retrieve_body($response);
6362 //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
6363
6364 if ($response_code !== 200) {
6365 //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
6366 $api_error_detail = '';
6367 $decoded_error = json_decode($response_body, true);
6368 if (isset($decoded_error['error']['message'])) {
6369 $api_error_detail = $decoded_error['error']['message'];
6370 }
6371 $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
6372 $this->current_valid_urls = [];
6373 return '';
6374 }
6375 $result = json_decode($response_body, true);
6376
6377 if (json_last_error() !== JSON_ERROR_NONE) {
6378 //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
6379 $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
6380 $this->current_valid_urls = [];
6381 return '';
6382 }
6383
6384 // Debug: Log the structure of the result
6385 //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
6386 if (isset($result['output'])) {
6387 //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
6388 foreach ($result['output'] as $idx => $out) {
6389 //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
6390 //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
6391 }
6392 } else {
6393 //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
6394 }
6395
6396 // Extract file search results from the response
6397 $content = '';
6398 $matches_used = 0;
6399 $all_matches = [];
6400
6401 // The Responses API returns output array with tool results
6402 if (isset($result['output']) && is_array($result['output'])) {
6403 foreach ($result['output'] as $output_item) {
6404 // Look for file_search_call results
6405 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
6406 //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
6407 //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
6408
6409 // Check for search_results in the output item directly
6410 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
6411 //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
6412
6413 if (empty($search_results)) {
6414 //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
6415 //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
6416 }
6417
6418 foreach ($search_results as $index => $search_result) {
6419 $filename = $search_result['filename'] ?? '';
6420 $score = $search_result['score'] ?? 0;
6421 $text_content = '';
6422
6423 // Extract text content from the result
6424 // The text can be directly on the result OR nested under content array
6425 if (isset($search_result['text']) && !empty($search_result['text'])) {
6426 // Direct text field (OpenAI's actual format)
6427 $text_content = $search_result['text'];
6428 //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
6429 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
6430 // Nested content array format
6431 foreach ($search_result['content'] as $content_item) {
6432 if (isset($content_item['text'])) {
6433 $text_content .= $content_item['text'] . "\n";
6434 }
6435 }
6436 //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
6437 } else {
6438 //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
6439 }
6440
6441 if (!empty($text_content)) {
6442 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
6443 $content .= trim($text_content) . "\n\n";
6444
6445 if (!empty($filename)) {
6446 $content .= "Source: " . $filename . "\n\n";
6447 }
6448
6449 // Extract URLs from content
6450 preg_match_all(
6451 '#\bhttps?://[^\s<>"\']+#i',
6452 $text_content,
6453 $content_urls
6454 );
6455 if (!empty($content_urls[0])) {
6456 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6457 }
6458
6459 $matches_used++;
6460 }
6461
6462 // Store for similarity analysis
6463 $all_matches[] = [
6464 'document_id' => $filename ?: ('result_' . $index),
6465 'similarity' => $score,
6466 'similarity_percentage' => round($score * 100, 2),
6467 'above_threshold' => true,
6468 'source_display' => $filename,
6469 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
6470 'used_for_context' => true,
6471 'role_restriction' => 'public',
6472 'has_access' => true,
6473 'filtered_out' => false
6474 ];
6475 }
6476 }
6477
6478 // Also check for message content with annotations (citations)
6479 if (isset($output_item['type']) && $output_item['type'] === 'message') {
6480 if (isset($output_item['content']) && is_array($output_item['content'])) {
6481 foreach ($output_item['content'] as $content_block) {
6482 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
6483 foreach ($content_block['annotations'] as $annotation) {
6484 if (isset($annotation['filename'])) {
6485 $filename = $annotation['filename'];
6486 $score = $annotation['score'] ?? 0;
6487 $text_content = '';
6488
6489 if (isset($annotation['content']) && is_array($annotation['content'])) {
6490 foreach ($annotation['content'] as $ann_content) {
6491 if (isset($ann_content['text'])) {
6492 $text_content .= $ann_content['text'] . "\n";
6493 }
6494 }
6495 }
6496
6497 if (!empty($text_content) && $matches_used < $max_results) {
6498 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
6499 $content .= trim($text_content) . "\n\n";
6500 $content .= "Source: " . $filename . "\n\n";
6501
6502 preg_match_all(
6503 '#\bhttps?://[^\s<>"\']+#i',
6504 $text_content,
6505 $content_urls
6506 );
6507 if (!empty($content_urls[0])) {
6508 $valid_urls = array_merge($valid_urls, $content_urls[0]);
6509 }
6510
6511 $matches_used++;
6512
6513 $all_matches[] = [
6514 'document_id' => $filename,
6515 'similarity' => $score,
6516 'similarity_percentage' => round($score * 100, 2),
6517 'above_threshold' => true,
6518 'source_display' => $filename,
6519 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
6520 'used_for_context' => true,
6521 'role_restriction' => 'public',
6522 'has_access' => true,
6523 'filtered_out' => false
6524 ];
6525 }
6526 }
6527 }
6528 }
6529 }
6530 }
6531 }
6532 }
6533 }
6534
6535 // Store for testing panel
6536 $this->last_similarity_analysis['top_matches'] = $all_matches;
6537 $this->last_similarity_analysis['total_checked'] = count($all_matches);
6538
6539 // Store unique valid URLs for validation
6540 $this->current_valid_urls = array_unique($valid_urls);
6541
6542 // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6543 do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6544
6545 //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
6546 //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
6547 //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
6548 //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
6549 if ($matches_used > 0) {
6550 //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
6551 }
6552
6553 // Check if citation links are enabled
6554 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
6555
6556 // Add response guidelines
6557 if ($matches_used === 0) {
6558 //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
6559 $content = "No reference information was found for this query.\n\n";
6560 } else {
6561 // Build response guidelines based on citation links setting
6562 $content .= "\n## Response Guidelines ##\n" .
6563 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6564 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6565 "If you don't have specific information or are uncertain about any details, it's always " .
6566 "better to honestly say you don't know rather than making up or guessing at answers. " .
6567 "When information is incomplete, let them know you are unsure.\n\n";
6568
6569 // Only add hyperlink instructions if citation links are enabled
6570 if ($citation_links_enabled) {
6571 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6572 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
6573 } else {
6574 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6575 "Simply provide helpful answers based on the reference information without citing sources.";
6576 }
6577 }
6578
6579 //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
6580
6581 return trim($content);
6582 }
6583
6584 /**
6585 * Check if the given model is an OpenAI chat model
6586 *
6587 * @param string $model The model ID
6588 * @return bool True if it's an OpenAI model
6589 */
6590 private function is_openai_chat_model($model) {
6591 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
6592 foreach ($openai_prefixes as $prefix) {
6593 if (strpos($model, $prefix) === 0) {
6594 return true;
6595 }
6596 }
6597 return false;
6598 }
6599
6600 /**
6601 * Get bot-specific Vector Store configuration
6602 *
6603 * @param string $bot_id The bot ID
6604 * @return array Configuration array
6605 */
6606 private function get_bot_vectorstore_config($bot_id = 'default') {
6607 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
6608
6609 // Default global settings
6610 $default_config = array(
6611 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
6612 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
6613 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
6614 );
6615
6616 // Allow multi-bot plugin to override with bot-specific settings
6617 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
6618
6619 // Preserve max_results from global settings if not set in bot config
6620 if (!isset($bot_config['max_results'])) {
6621 $bot_config['max_results'] = $default_config['max_results'];
6622 }
6623
6624 return $bot_config;
6625 }
6626
6627 private function mxchat_find_relevant_products($user_embedding) {
6628 //error_log('MXChat Vector Search: Starting product search...');
6629
6630 // Retrieve the add-on settings from the database
6631 $addon_options = get_option('mxchat_pinecone_addon_options', array());
6632
6633 // Determine whether Pinecone is enabled
6634 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6635
6636 //error_log('Pinecone enabled flag: ' . $use_pinecone);
6637
6638 if ($use_pinecone === 1) {
6639 //error_log('MXChat Vector Search: Using Pinecone database for products');
6640 return $this->find_relevant_products_pinecone($user_embedding);
6641 } else {
6642 //error_log('MXChat Vector Search: Using WordPress database for products');
6643 return $this->find_relevant_products_wordpress($user_embedding);
6644 }
6645 }
6646 private function find_relevant_products_wordpress($user_embedding) {
6647 global $wpdb;
6648 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6649
6650 if (!is_array($user_embedding)) {
6651 return '';
6652 }
6653
6654 // Streaming top-K pass: scan rows in small batches, keep only the top 3
6655 // results above the similarity threshold. Peak memory is bounded by
6656 // $batch_size embedding rows plus a 3-element top list.
6657 $batch_size = 250;
6658 $similarity_threshold = 0.85;
6659 $top_k = 3;
6660 $top_results = [];
6661 $offset = 0;
6662
6663 do {
6664 $batch = $wpdb->get_results($wpdb->prepare(
6665 "SELECT id, embedding_vector
6666 FROM {$system_prompt_table}
6667 LIMIT %d OFFSET %d",
6668 $batch_size,
6669 $offset
6670 ));
6671
6672 if (empty($batch)) {
6673 break;
6674 }
6675
6676 foreach ($batch as $row) {
6677 $database_embedding = $row->embedding_vector
6678 ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6679 : null;
6680
6681 if (!is_array($database_embedding)) {
6682 unset($database_embedding);
6683 continue;
6684 }
6685
6686 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6687 unset($database_embedding);
6688
6689 if ($similarity < $similarity_threshold) {
6690 continue;
6691 }
6692
6693 // Insert into bounded top-K (kept sorted descending)
6694 if (count($top_results) < $top_k) {
6695 $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
6696 usort($top_results, function ($a, $b) {
6697 return $b['similarity'] <=> $a['similarity'];
6698 });
6699 } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
6700 $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
6701 usort($top_results, function ($a, $b) {
6702 return $b['similarity'] <=> $a['similarity'];
6703 });
6704 }
6705 }
6706
6707 unset($batch);
6708 $offset += $batch_size;
6709 } while (true);
6710
6711 if (empty($top_results)) {
6712 return '';
6713 }
6714
6715 $content = '';
6716 foreach ($top_results as $result) {
6717 $chunk_content = $this->fetch_content_with_product_links($result['id']);
6718 $content .= $chunk_content . "\n\n";
6719 }
6720
6721 return trim($content);
6722 }
6723
6724
6725 private function find_relevant_products_pinecone($user_embedding) {
6726 //error_log('Starting Pinecone product search...');
6727
6728 $options = get_option('mxchat_pinecone_addon_options', array());
6729 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
6730 $host = $options['mxchat_pinecone_host'] ?? '';
6731
6732 if (empty($host) || empty($api_key)) {
6733 //error_log('Pinecone credentials not properly configured for product search');
6734 return '';
6735 }
6736
6737 $similarity_threshold = 0.85;
6738 $api_endpoint = "https://{$host}/query";
6739
6740 $request_body = array(
6741 'vector' => $user_embedding,
6742 'topK' => 5,
6743 'includeMetadata' => true,
6744 'includeValues' => true,
6745 'filter' => array(
6746 'type' => 'product'
6747 )
6748 );
6749
6750 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
6751
6752 $response = wp_remote_post($api_endpoint, array(
6753 'headers' => array(
6754 'Api-Key' => $api_key,
6755 'accept' => 'application/json',
6756 'content-type' => 'application/json'
6757 ),
6758 'body' => wp_json_encode($request_body),
6759 'timeout' => 30
6760 ));
6761
6762 if (is_wp_error($response)) {
6763 //error_log('Pinecone product query error: ' . $response->get_error_message());
6764 return '';
6765 }
6766
6767 $response_code = wp_remote_retrieve_response_code($response);
6768 //error_log('Pinecone response code: ' . $response_code);
6769
6770 if ($response_code !== 200) {
6771 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
6772 return '';
6773 }
6774
6775 $results = json_decode(wp_remote_retrieve_body($response), true);
6776 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
6777
6778 if (empty($results['matches'])) {
6779 //error_log('No matches found in Pinecone response');
6780 return '';
6781 }
6782
6783 $content = '';
6784 foreach ($results['matches'] as $match) {
6785 if ($match['score'] < $similarity_threshold) {
6786 //error_log("Match below threshold: " . $match['score']);
6787 continue;
6788 }
6789
6790 if (!empty($match['metadata']['text'])) {
6791 $content .= $match['metadata']['text'];
6792 if (!empty($match['metadata']['source_url'])) {
6793 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
6794 }
6795 $content .= "\n\n";
6796 }
6797 }
6798
6799 return trim($content);
6800 }
6801
6802
6803 private function fetch_content_with_product_links($most_relevant_id) {
6804 global $wpdb;
6805 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
6806
6807 // Fetch the article content and associated product URL
6808 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
6809 $result = $wpdb->get_row($query);
6810
6811 if ($result) {
6812 // Append the product link to the content if available
6813 $content = $result->article_content;
6814 if (!empty($result->source_url)) {
6815 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
6816 }
6817 return $content;
6818 }
6819
6820 return null;
6821 }
6822
6823 /**
6824 * Get system instructions for a specific bot or default
6825 * Checks for multi-bot add-on and uses bot-specific instructions if available
6826 * Automatically strips URLs if citation links are disabled
6827 * Replaces {visitor_name} placeholder with actual visitor name if available
6828 *
6829 * @param string $bot_id The bot ID to get instructions for
6830 * @param string $session_id Optional session ID to lookup visitor name
6831 */
6832 private function get_system_instructions($bot_id = 'default', $session_id = '') {
6833 $instructions = '';
6834
6835 // Check if multi-bot add-on is active
6836 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
6837 // Get bot-specific options from multi-bot add-on
6838 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
6839
6840 // If bot has custom system instructions, use those
6841 if (!empty($bot_options['system_prompt_instructions'])) {
6842 $instructions = $bot_options['system_prompt_instructions'];
6843 }
6844 }
6845
6846 // Fall back to default system instructions
6847 if (empty($instructions)) {
6848 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6849 }
6850
6851 // Check if citation links are disabled - if so, strip URLs from instructions
6852 $fresh_options = get_option('mxchat_options', []);
6853 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6854
6855 if (!$citation_links_enabled && !empty($instructions)) {
6856 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6857 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6858 }
6859
6860 // Replace {visitor_name} placeholder with actual visitor name if available
6861 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6862 $name_option_key = "mxchat_name_{$session_id}";
6863 $visitor_name = get_option($name_option_key, '');
6864
6865 if (!empty($visitor_name)) {
6866 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6867 } else {
6868 // Remove placeholder if no name is available
6869 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6870 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6871 }
6872 }
6873
6874 // Allow developers to filter system instructions and process shortcodes
6875 $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
6876 $instructions = do_shortcode($instructions);
6877
6878 return $instructions;
6879 }
6880 /**
6881 * Get the current bot ID from session or request context
6882 */
6883 private function get_current_bot_id($session_id = '') {
6884 // First, check if bot_id is passed in the current request
6885 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6886 return sanitize_key($_POST['bot_id']);
6887 }
6888
6889 // If not in POST, try to get it from session data
6890 if (!empty($session_id)) {
6891 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6892 if (!empty($bot_id)) {
6893 return $bot_id;
6894 }
6895 }
6896
6897 // Fall back to default
6898 return 'default';
6899 }
6900 private function mxchat_generate_response($relevant_content, $api_key, $xai_api_key, $claude_api_key, $deepseek_api_key, $gemini_api_key, $openrouter_api_key, $conversation_history, $streaming = false, $session_id = '', $testing_data = null, $selected_model = 'gpt-5.1-chat-latest') {
6901 try {
6902 if (!$relevant_content) {
6903 $error_response = [
6904 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6905 'error_code' => 'no_relevant_content'
6906 ];
6907
6908 if ($testing_data !== null) {
6909 $error_response['testing_data'] = $testing_data;
6910 }
6911
6912 return $error_response;
6913 }
6914
6915 if (!is_array($conversation_history)) {
6916 $conversation_history = array();
6917 }
6918
6919 // Check if this is an OpenRouter model
6920 if ($selected_model === 'openrouter') {
6921 // Get the actual OpenRouter model from options
6922 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6923
6924 if (empty($openrouter_selected_model)) {
6925 $error_response = [
6926 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6927 'error_code' => 'no_openrouter_model_selected'
6928 ];
6929 if ($testing_data !== null) {
6930 $error_response['testing_data'] = $testing_data;
6931 }
6932 return $error_response;
6933 }
6934
6935 if (empty($openrouter_api_key)) {
6936 $error_response = [
6937 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6938 'error_code' => 'missing_openrouter_api_key'
6939 ];
6940 if ($testing_data !== null) {
6941 $error_response['testing_data'] = $testing_data;
6942 }
6943 return $error_response;
6944 }
6945
6946 if ($streaming) {
6947 return $this->mxchat_generate_response_openrouter_stream(
6948 $openrouter_selected_model,
6949 $openrouter_api_key,
6950 $conversation_history,
6951 $relevant_content,
6952 $session_id,
6953 $testing_data
6954 );
6955 } else {
6956 $response = $this->mxchat_generate_response_openrouter(
6957 $openrouter_selected_model,
6958 $openrouter_api_key,
6959 $conversation_history,
6960 $relevant_content
6961 );
6962 }
6963
6964 if (is_array($response) && isset($response['error'])) {
6965 if ($testing_data !== null) {
6966 $response['testing_data'] = $testing_data;
6967 }
6968 return $response;
6969 }
6970
6971 return $response;
6972 }
6973
6974 // Extract model prefix to determine the provider
6975 $model_parts = explode('-', $selected_model);
6976 $provider = strtolower($model_parts[0]);
6977
6978 // Handle model selection based on provider prefix
6979 switch ($provider) {
6980 case 'gemini':
6981 if (empty($gemini_api_key)) {
6982 $error_response = [
6983 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6984 'error_code' => 'missing_gemini_api_key'
6985 ];
6986 if ($testing_data !== null) {
6987 $error_response['testing_data'] = $testing_data;
6988 }
6989 return $error_response;
6990 }
6991 $response = $this->mxchat_generate_response_gemini(
6992 $selected_model,
6993 $gemini_api_key,
6994 $conversation_history,
6995 $relevant_content
6996 );
6997 break;
6998
6999 case 'claude':
7000 if (empty($claude_api_key)) {
7001 $error_response = [
7002 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
7003 'error_code' => 'missing_claude_api_key'
7004 ];
7005 if ($testing_data !== null) {
7006 $error_response['testing_data'] = $testing_data;
7007 }
7008 return $error_response;
7009 }
7010 if ($streaming) {
7011 return $this->mxchat_generate_response_claude_stream(
7012 $selected_model,
7013 $claude_api_key,
7014 $conversation_history,
7015 $relevant_content,
7016 $session_id,
7017 $testing_data
7018 );
7019 } else {
7020 $response = $this->mxchat_generate_response_claude(
7021 $selected_model,
7022 $claude_api_key,
7023 $conversation_history,
7024 $relevant_content
7025 );
7026 }
7027 break;
7028
7029 case 'grok':
7030 if (empty($xai_api_key)) {
7031 $error_response = [
7032 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
7033 'error_code' => 'missing_xai_api_key'
7034 ];
7035 if ($testing_data !== null) {
7036 $error_response['testing_data'] = $testing_data;
7037 }
7038 return $error_response;
7039 }
7040 if ($streaming) {
7041 return $this->mxchat_generate_response_xai_stream(
7042 $selected_model,
7043 $xai_api_key,
7044 $conversation_history,
7045 $relevant_content,
7046 $session_id,
7047 $testing_data
7048 );
7049 } else {
7050 $response = $this->mxchat_generate_response_xai(
7051 $selected_model,
7052 $xai_api_key,
7053 $conversation_history,
7054 $relevant_content
7055 );
7056 }
7057 break;
7058
7059 case 'deepseek':
7060 if (empty($deepseek_api_key)) {
7061 $error_response = [
7062 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
7063 'error_code' => 'missing_deepseek_api_key'
7064 ];
7065 if ($testing_data !== null) {
7066 $error_response['testing_data'] = $testing_data;
7067 }
7068 return $error_response;
7069 }
7070 if ($streaming) {
7071 return $this->mxchat_generate_response_deepseek_stream(
7072 $selected_model,
7073 $deepseek_api_key,
7074 $conversation_history,
7075 $relevant_content,
7076 $session_id,
7077 $testing_data
7078 );
7079 } else {
7080 $response = $this->mxchat_generate_response_deepseek(
7081 $selected_model,
7082 $deepseek_api_key,
7083 $conversation_history,
7084 $relevant_content
7085 );
7086 }
7087 break;
7088
7089 case 'custom':
7090 // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
7091 $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
7092 if (empty($cp_base_url)) {
7093 $error_response = [
7094 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
7095 'error_code' => 'missing_custom_provider_base_url'
7096 ];
7097 if ($testing_data !== null) {
7098 $error_response['testing_data'] = $testing_data;
7099 }
7100 return $error_response;
7101 }
7102 if ($streaming) {
7103 return $this->mxchat_generate_response_custom_stream(
7104 $selected_model,
7105 $conversation_history,
7106 $relevant_content,
7107 $session_id,
7108 $testing_data
7109 );
7110 } else {
7111 $response = $this->mxchat_generate_response_custom(
7112 $selected_model,
7113 $conversation_history,
7114 $relevant_content
7115 );
7116 }
7117 break;
7118
7119 case 'gpt':
7120 case 'o1':
7121 if (empty($api_key)) {
7122 $error_response = [
7123 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
7124 'error_code' => 'missing_openai_api_key'
7125 ];
7126 if ($testing_data !== null) {
7127 $error_response['testing_data'] = $testing_data;
7128 }
7129 return $error_response;
7130 }
7131
7132 // Check if web search is enabled for this OpenAI model
7133 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7134 // Models that don't support web search
7135 $unsupported_web_search_models = array('gpt-4.1-nano');
7136 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
7137
7138 if ($web_search_enabled && $model_supports_web_search) {
7139 // Use Responses API (required for some models, or when web search is enabled)
7140 return $this->mxchat_generate_response_openai_web_search(
7141 $selected_model,
7142 $api_key,
7143 $conversation_history,
7144 $relevant_content,
7145 $session_id,
7146 $testing_data,
7147 $streaming
7148 );
7149 } elseif ($streaming) {
7150 return $this->mxchat_generate_response_openai_stream(
7151 $selected_model,
7152 $api_key,
7153 $conversation_history,
7154 $relevant_content,
7155 $session_id,
7156 $testing_data
7157 );
7158 } else {
7159 $response = $this->mxchat_generate_response_openai(
7160 $selected_model,
7161 $api_key,
7162 $conversation_history,
7163 $relevant_content
7164 );
7165 }
7166 break;
7167
7168 default:
7169 if (empty($api_key)) {
7170 $error_response = [
7171 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
7172 'error_code' => 'missing_openai_api_key'
7173 ];
7174 if ($testing_data !== null) {
7175 $error_response['testing_data'] = $testing_data;
7176 }
7177 return $error_response;
7178 }
7179
7180 // Check if web search is enabled (default case also handles OpenAI models)
7181 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
7182 $unsupported_web_search_models = array('gpt-4.1-nano');
7183 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
7184
7185 if ($web_search_enabled && $model_supports_web_search) {
7186 return $this->mxchat_generate_response_openai_web_search(
7187 $selected_model,
7188 $api_key,
7189 $conversation_history,
7190 $relevant_content,
7191 $session_id,
7192 $testing_data,
7193 $streaming
7194 );
7195 } elseif ($streaming) {
7196 return $this->mxchat_generate_response_openai_stream(
7197 $selected_model,
7198 $api_key,
7199 $conversation_history,
7200 $relevant_content,
7201 $session_id,
7202 $testing_data
7203 );
7204 } else {
7205 $response = $this->mxchat_generate_response_openai(
7206 $selected_model,
7207 $api_key,
7208 $conversation_history,
7209 $relevant_content
7210 );
7211 }
7212 break;
7213 }
7214
7215 if (is_array($response) && isset($response['error'])) {
7216 if ($testing_data !== null) {
7217 $response['testing_data'] = $testing_data;
7218 }
7219 return $response;
7220 }
7221
7222 return $response;
7223
7224 } catch (Exception $e) {
7225 $error_response = [
7226 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
7227 'error_code' => 'system_exception',
7228 'exception_details' => $e->getMessage()
7229 ];
7230
7231 if ($testing_data !== null) {
7232 $error_response['testing_data'] = $testing_data;
7233 }
7234
7235 return $error_response;
7236 }
7237 }
7238 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7239 try {
7240 $bot_id = $this->get_current_bot_id($session_id);
7241 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7242
7243 if (!is_array($conversation_history)) {
7244 $conversation_history = array();
7245 }
7246
7247 $formatted_conversation = array();
7248
7249 $formatted_conversation[] = array(
7250 'role' => 'system',
7251 'content' => $system_prompt_instructions . " " . $relevant_content
7252 );
7253
7254 foreach ($conversation_history as $message) {
7255 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7256 $role = $message['role'];
7257 if ($role === 'bot' || $role === 'agent') {
7258 $role = 'assistant';
7259 }
7260 if (!in_array($role, ['system', 'assistant', 'user'])) {
7261 $role = 'user';
7262 }
7263 $formatted_conversation[] = array(
7264 'role' => $role,
7265 'content' => $message['content']
7266 );
7267 }
7268 }
7269
7270 if (headers_sent() || !function_exists('curl_init')) {
7271 $regular_response = $this->mxchat_generate_response_openrouter(
7272 $selected_model,
7273 $openrouter_api_key,
7274 $conversation_history,
7275 $relevant_content
7276 );
7277
7278 // Save bot response to transcript
7279 if (!empty($regular_response) && !empty($session_id)) {
7280 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7281 }
7282
7283 $response_data = [
7284 'text' => $regular_response,
7285 'html' => '',
7286 'session_id' => $session_id
7287 ];
7288
7289 if ($testing_data !== null) {
7290 $response_data['testing_data'] = $testing_data;
7291 }
7292
7293 header('Content-Type: application/json');
7294 echo json_encode($response_data);
7295 return true;
7296 }
7297
7298 $body = json_encode([
7299 'model' => $selected_model,
7300 'messages' => $formatted_conversation,
7301 'temperature' => 1,
7302 'stream' => true
7303 ]);
7304
7305 // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
7306 // inside WRITEFUNCTION on first byte of a successful upstream.
7307
7308 $captured_status_code = 0;
7309 $captured_body_pre_stream = '';
7310 $full_response = '';
7311 $stream_started = false;
7312 $buffer = '';
7313 $errno = 0;
7314 $last_curl_error = '';
7315 $http_code = 0;
7316 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
7317 $backoff_ms = array(0, 750, 2000);
7318
7319 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
7320 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
7321 usleep($backoff_ms[$attempt] * 1000);
7322 }
7323
7324 $captured_status_code = 0;
7325 $captured_body_pre_stream = '';
7326 $full_response = '';
7327 $stream_started = false;
7328 $buffer = '';
7329
7330 $ch = curl_init();
7331 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
7332 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7333 curl_setopt($ch, CURLOPT_POST, true);
7334 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7335 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7336 'Content-Type: application/json',
7337 'Authorization: Bearer ' . $openrouter_api_key,
7338 'HTTP-Referer: ' . home_url(),
7339 'X-Title: ' . get_bloginfo('name')
7340 ));
7341 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7342 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7343
7344 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
7345 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
7346 $captured_status_code = (int) $m[1];
7347 }
7348 return strlen($header);
7349 });
7350
7351 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
7352 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
7353 $captured_body_pre_stream .= $data;
7354 return strlen($data);
7355 }
7356
7357 if (!$this->streaming_headers_sent) {
7358 $this->setup_streaming_headers();
7359 }
7360
7361 if (!$stream_started && $testing_data !== null) {
7362 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7363 flush();
7364 $stream_started = true;
7365 }
7366
7367 $buffer .= $data;
7368 $lines = explode("\n", $buffer);
7369 $buffer = array_pop($lines);
7370
7371 foreach ($lines as $line) {
7372 if (trim($line) === '') {
7373 continue;
7374 }
7375 if (strpos($line, 'data: ') !== 0) {
7376 continue;
7377 }
7378
7379 $json_str = substr($line, 6);
7380
7381 if (trim($json_str) === '[DONE]') {
7382 echo "data: [DONE]\n\n";
7383 flush();
7384 continue;
7385 }
7386
7387 $json = json_decode(trim($json_str), true);
7388 if ($json && isset($json['choices'][0]['delta']['content'])) {
7389 $content = $json['choices'][0]['delta']['content'];
7390 $full_response .= $content;
7391
7392 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7393 flush();
7394 }
7395 }
7396
7397 return strlen($data);
7398 });
7399
7400 $response = curl_exec($ch);
7401 $errno = curl_errno($ch);
7402 $last_curl_error = curl_error($ch);
7403 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
7404 curl_close($ch);
7405
7406 if (!$errno && $http_code === 200) {
7407 break;
7408 }
7409
7410 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
7411 $can_retry = !$this->streaming_headers_sent
7412 && ($attempt + 1) < $max_attempts
7413 && $is_transient;
7414
7415 if (defined('WP_DEBUG') && WP_DEBUG) {
7416 error_log(sprintf(
7417 '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
7418 $attempt + 1, $max_attempts, $http_code, $errno,
7419 $is_transient ? 'yes' : 'no',
7420 $can_retry ? 'Retrying.' : 'Giving up.'
7421 ));
7422 }
7423
7424 if (!$can_retry) {
7425 break;
7426 }
7427 }
7428
7429 if (!$errno && $http_code === 200) {
7430 if (!empty($full_response) && !empty($session_id)) {
7431 $rag_context_for_storage = null;
7432 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7433 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7434
7435 if ($has_rag_data || $has_action_data) {
7436 $rag_context_for_storage = [];
7437
7438 if ($has_rag_data) {
7439 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7440 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7441 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7442 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7443 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7444 }
7445
7446 if ($has_action_data) {
7447 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7448 }
7449 }
7450 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7451 }
7452 return true;
7453 }
7454
7455 return $this->mxchat_stream_emit_fallback(
7456 'openai',
7457 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content),
7458 $session_id,
7459 $testing_data
7460 );
7461
7462 } catch (Exception $e) {
7463 return $this->mxchat_stream_emit_fallback(
7464 'openai',
7465 $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content),
7466 $session_id,
7467 $testing_data
7468 );
7469 }
7470 }
7471 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7472 try {
7473 $bot_id = $this->get_current_bot_id($session_id);
7474
7475 // Get system prompt instructions using centralized function
7476 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7477
7478 // Ensure conversation_history is an array
7479 if (!is_array($conversation_history)) {
7480 $conversation_history = array();
7481 }
7482
7483 // Format conversation history for OpenAI
7484 $formatted_conversation = array();
7485
7486 $formatted_conversation[] = array(
7487 'role' => 'system',
7488 'content' => $system_prompt_instructions . " " . $relevant_content
7489 );
7490
7491 foreach ($conversation_history as $message) {
7492 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7493 $role = $message['role'];
7494 if ($role === 'bot' || $role === 'agent') {
7495 $role = 'assistant';
7496 }
7497 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7498 $role = 'user';
7499 }
7500 $formatted_conversation[] = array(
7501 'role' => $role,
7502 'content' => $message['content']
7503 );
7504 }
7505 }
7506
7507 // Check if we can actually stream
7508 if (headers_sent() || !function_exists('curl_init')) {
7509 // Fallback to regular response with testing data
7510 $regular_response = $this->mxchat_generate_response_openai(
7511 $selected_model,
7512 $api_key,
7513 $conversation_history,
7514 $relevant_content
7515 );
7516
7517 // Save bot response to transcript
7518 if (!empty($regular_response) && !empty($session_id)) {
7519 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7520 }
7521
7522 $response_data = [
7523 'text' => $regular_response,
7524 'html' => '',
7525 'session_id' => $session_id
7526 ];
7527
7528 if ($testing_data !== null) {
7529 $response_data['testing_data'] = $testing_data;
7530 }
7531
7532 header('Content-Type: application/json');
7533 echo json_encode($response_data);
7534 return true;
7535 }
7536
7537 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
7538 $is_gpt5_model = (
7539 strpos($selected_model, 'gpt-5') === 0 ||
7540 $selected_model === 'gpt-5.2' ||
7541 $selected_model === 'gpt-5.1-2025-11-13' ||
7542 $selected_model === 'gpt-5' ||
7543 $selected_model === 'gpt-5-mini' ||
7544 $selected_model === 'gpt-5-nano'
7545 );
7546
7547 // Build request body with optimal settings for fast streaming
7548 $request_body = [
7549 'model' => $selected_model,
7550 'messages' => $formatted_conversation,
7551 'temperature' => 1,
7552 'stream' => true
7553 ];
7554
7555 // Add reasoning_effort only for GPT-5 models that support it
7556 // These chat models don't support reasoning_effort parameter
7557 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
7558 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
7559 // GPT-5.1 uses 'low' instead of 'minimal'
7560 if ($selected_model === 'gpt-5.1-2025-11-13') {
7561 $request_body['reasoning_effort'] = 'low';
7562 } elseif ($selected_model === 'gpt-5.5') {
7563 $request_body['reasoning_effort'] = 'none';
7564 } elseif ($selected_model === 'gpt-5.4') {
7565 $request_body['reasoning_effort'] = 'none';
7566 } else {
7567 $request_body['reasoning_effort'] = 'minimal';
7568 }
7569 }
7570
7571 $body = json_encode($request_body);
7572
7573 // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
7574 // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
7575 // SUCCESSFUL upstream response, gated by the captured HTTP status.
7576
7577 $captured_status_code = 0;
7578 $captured_body_pre_stream = '';
7579 $full_response = '';
7580 $stream_started = false;
7581 $buffer = '';
7582 $errno = 0;
7583 $last_curl_error = '';
7584 $http_code = 0;
7585 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
7586 $backoff_ms = array(0, 750, 2000);
7587
7588 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
7589 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
7590 usleep($backoff_ms[$attempt] * 1000);
7591 }
7592
7593 // Reset per-attempt capture state.
7594 $captured_status_code = 0;
7595 $captured_body_pre_stream = '';
7596 $full_response = '';
7597 $stream_started = false;
7598 $buffer = '';
7599
7600 $ch = curl_init();
7601 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
7602 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7603 curl_setopt($ch, CURLOPT_POST, true);
7604 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7605 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7606 'Content-Type: application/json',
7607 'Authorization: Bearer ' . $api_key
7608 ));
7609 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7610 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7611
7612 // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
7613 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
7614 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
7615 $captured_status_code = (int) $m[1];
7616 }
7617 return strlen($header);
7618 });
7619
7620 // Buffer control for real-time streaming
7621 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
7622 // V2 guard: if upstream returned non-200, buffer body for transient
7623 // classification and DO NOT emit to client. Stream channel must NOT open.
7624 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
7625 $captured_body_pre_stream .= $data;
7626 return strlen($data);
7627 }
7628
7629 // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
7630 // After this point streaming_headers_sent === true → retry is structurally blocked.
7631 if (!$this->streaming_headers_sent) {
7632 $this->setup_streaming_headers();
7633 }
7634
7635 // Send testing data as the first event if available
7636 if (!$stream_started && $testing_data !== null) {
7637 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7638 flush();
7639 $stream_started = true;
7640 }
7641
7642 // CRITICAL FIX: Append new data to buffer
7643 $buffer .= $data;
7644
7645 // Process complete lines only
7646 $lines = explode("\n", $buffer);
7647
7648 // CRITICAL FIX: Keep the last incomplete line in the buffer
7649 $buffer = array_pop($lines);
7650
7651 foreach ($lines as $line) {
7652 if (trim($line) === '') {
7653 continue;
7654 }
7655 if (strpos($line, 'data: ') !== 0) {
7656 continue;
7657 }
7658
7659 $json_str = substr($line, 6);
7660
7661 if (trim($json_str) === '[DONE]') {
7662 echo "data: [DONE]\n\n";
7663 flush();
7664 continue;
7665 }
7666
7667 $json = json_decode(trim($json_str), true);
7668 if ($json && isset($json['choices'][0]['delta']['content'])) {
7669 $content = $json['choices'][0]['delta']['content'];
7670 $full_response .= $content;
7671
7672 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7673 flush();
7674 }
7675 }
7676
7677 return strlen($data);
7678 });
7679
7680 $response = curl_exec($ch);
7681 $errno = curl_errno($ch);
7682 $last_curl_error = curl_error($ch);
7683 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
7684 curl_close($ch);
7685
7686 if (!$errno && $http_code === 200) {
7687 break; // Happy path — WRITEFUNCTION already streamed everything.
7688 }
7689
7690 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
7691 $can_retry = !$this->streaming_headers_sent
7692 && ($attempt + 1) < $max_attempts
7693 && $is_transient;
7694
7695 if (defined('WP_DEBUG') && WP_DEBUG) {
7696 error_log(sprintf(
7697 '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
7698 $attempt + 1, $max_attempts, $http_code, $errno,
7699 $is_transient ? 'yes' : 'no',
7700 $can_retry ? 'Retrying.' : 'Giving up.'
7701 ));
7702 }
7703
7704 if (!$can_retry) {
7705 break;
7706 }
7707 }
7708
7709 // Post-loop branch.
7710 if (!$errno && $http_code === 200) {
7711 // Happy path — save the complete response to maintain chat persistence.
7712 if (!empty($full_response) && !empty($session_id)) {
7713 $rag_context_for_storage = null;
7714 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7715 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7716
7717 if ($has_rag_data || $has_action_data) {
7718 $rag_context_for_storage = [];
7719
7720 if ($has_rag_data) {
7721 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7722 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7723 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7724 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7725 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7726 }
7727
7728 if ($has_action_data) {
7729 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7730 }
7731 }
7732 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7733 }
7734
7735 return true;
7736 }
7737
7738 // Failure path — branch on whether SSE channel was opened.
7739 return $this->mxchat_stream_emit_fallback(
7740 'openai',
7741 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content),
7742 $session_id,
7743 $testing_data
7744 );
7745
7746 } catch (Exception $e) {
7747 return $this->mxchat_stream_emit_fallback(
7748 'openai',
7749 $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content),
7750 $session_id,
7751 $testing_data
7752 );
7753 }
7754 }
7755
7756 /**
7757 * Shared fallback emitter for streaming chat functions. Two outcomes:
7758 * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
7759 * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
7760 * normal bot bubble. Transcript row is persisted.
7761 * - streaming_headers_sent === false: SSE channel never opened (retries
7762 * exhausted on initial connect). Emit a clean JSON response — the path
7763 * the widget would normally hit if streaming wasn't even attempted.
7764 *
7765 * Used by all six *_stream functions after their per-attempt retry loop.
7766 */
7767 private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
7768 $is_error_array = is_array($regular_response) && isset($regular_response['error']);
7769
7770 if ($this->streaming_headers_sent) {
7771 if ($is_error_array) {
7772 echo "data: " . json_encode([
7773 'error' => true,
7774 'error_message' => $regular_response['error'],
7775 'error_code' => $regular_response['error_code'] ?? 'api_error',
7776 'text' => $regular_response['error'],
7777 'message' => $regular_response['error']
7778 ]) . "\n\n";
7779 echo "data: [DONE]\n\n";
7780 flush();
7781 return true;
7782 }
7783 $fallback_message = (string) $regular_response;
7784 if (!empty($fallback_message) && !empty($session_id)) {
7785 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
7786 }
7787 echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
7788 echo "data: [DONE]\n\n";
7789 flush();
7790 return true;
7791 }
7792
7793 // SSE channel never opened — clean JSON fallback.
7794 if ($is_error_array) {
7795 header('Content-Type: application/json');
7796 echo json_encode(array(
7797 'error' => true,
7798 'error_message' => $regular_response['error'],
7799 'error_code' => $regular_response['error_code'] ?? 'api_error',
7800 'text' => $regular_response['error'],
7801 'message' => $regular_response['error'],
7802 ));
7803 return true;
7804 }
7805
7806 $fallback_message = (string) $regular_response;
7807 if (!empty($fallback_message) && !empty($session_id)) {
7808 $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
7809 }
7810 $response_data = array(
7811 'text' => $fallback_message,
7812 'html' => '',
7813 'session_id' => $session_id,
7814 );
7815 if ($testing_data !== null) {
7816 $response_data['testing_data'] = $testing_data;
7817 }
7818 header('Content-Type: application/json');
7819 echo json_encode($response_data);
7820 return true;
7821 }
7822
7823 /**
7824 * Resolve custom (OpenAI-compatible) provider config from settings.
7825 * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
7826 */
7827 private function mxchat_resolve_custom_provider() {
7828 $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
7829 $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
7830 $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
7831 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
7832 $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
7833
7834 $chat_url = $base_url . '/chat/completions';
7835 if (!empty($api_version)) {
7836 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
7837 }
7838
7839 $headers = array('Content-Type: application/json');
7840 if (!empty($api_key)) {
7841 if ($auth_scheme === 'api-key') {
7842 $headers[] = 'api-key: ' . $api_key;
7843 } else {
7844 $headers[] = 'Authorization: Bearer ' . $api_key;
7845 }
7846 }
7847
7848 return array(
7849 'base_url' => $base_url,
7850 'api_key' => $api_key,
7851 'model' => $model !== '' ? $model : 'default',
7852 'auth_scheme' => $auth_scheme,
7853 'api_version' => $api_version,
7854 'chat_url' => $chat_url,
7855 'headers' => $headers,
7856 );
7857 }
7858
7859 /**
7860 * Streaming chat completion against an OpenAI-compatible custom provider
7861 * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
7862 * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
7863 */
7864 private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7865 try {
7866 $cfg = $this->mxchat_resolve_custom_provider();
7867 if (empty($cfg['base_url'])) {
7868 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
7869 }
7870
7871 $bot_id = $this->get_current_bot_id($session_id);
7872 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7873 if (!is_array($conversation_history)) {
7874 $conversation_history = array();
7875 }
7876
7877 $formatted_conversation = array();
7878 $formatted_conversation[] = array(
7879 'role' => 'system',
7880 'content' => $system_prompt_instructions . ' ' . $relevant_content,
7881 );
7882 foreach ($conversation_history as $message) {
7883 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7884 $role = $message['role'];
7885 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
7886 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
7887 $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
7888 }
7889 }
7890
7891 if (headers_sent() || !function_exists('curl_init')) {
7892 // No streaming capability — fall through to non-stream wrapper
7893 $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
7894 if (!empty($regular) && !empty($session_id) && is_string($regular)) {
7895 $this->mxchat_save_chat_message($session_id, 'bot', $regular);
7896 }
7897 $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
7898 if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
7899 header('Content-Type: application/json');
7900 echo json_encode($response_data);
7901 return true;
7902 }
7903
7904 $request_body = array(
7905 'model' => $cfg['model'],
7906 'messages' => $formatted_conversation,
7907 'stream' => true,
7908 );
7909 $body = json_encode($request_body);
7910
7911 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
7912
7913 $captured_status_code = 0;
7914 $captured_body_pre_stream = '';
7915 $full_response = '';
7916 $stream_started = false;
7917 $buffer = '';
7918 $errno = 0;
7919 $http_code = 0;
7920 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
7921 $backoff_ms = array(0, 750, 2000);
7922
7923 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
7924 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
7925 usleep($backoff_ms[$attempt] * 1000);
7926 }
7927
7928 $captured_status_code = 0;
7929 $captured_body_pre_stream = '';
7930 $full_response = '';
7931 $stream_started = false;
7932 $buffer = '';
7933
7934 $ch = curl_init();
7935 curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
7936 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7937 curl_setopt($ch, CURLOPT_POST, true);
7938 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7939 curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
7940 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7941 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
7942
7943 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
7944 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
7945 $captured_status_code = (int) $m[1];
7946 }
7947 return strlen($header);
7948 });
7949
7950 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
7951 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
7952 $captured_body_pre_stream .= $data;
7953 return strlen($data);
7954 }
7955
7956 if (!$this->streaming_headers_sent) {
7957 $this->setup_streaming_headers();
7958 }
7959
7960 if (!$stream_started && $testing_data !== null) {
7961 echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
7962 flush();
7963 $stream_started = true;
7964 }
7965 $buffer .= $data;
7966 $lines = explode("\n", $buffer);
7967 $buffer = array_pop($lines);
7968 foreach ($lines as $line) {
7969 if (trim($line) === '') { continue; }
7970 if (strpos($line, 'data: ') !== 0) { continue; }
7971 $json_str = substr($line, 6);
7972 if (trim($json_str) === '[DONE]') {
7973 echo "data: [DONE]\n\n";
7974 flush();
7975 continue;
7976 }
7977 $json = json_decode(trim($json_str), true);
7978 if ($json && isset($json['choices'][0]['delta']['content'])) {
7979 $content = $json['choices'][0]['delta']['content'];
7980 $full_response .= $content;
7981 echo "data: " . json_encode(array('content' => $content)) . "\n\n";
7982 flush();
7983 }
7984 }
7985 return strlen($data);
7986 });
7987
7988 $response = curl_exec($ch);
7989 $errno = curl_errno($ch);
7990 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
7991 curl_close($ch);
7992
7993 if (!$errno && $http_code === 200) {
7994 break;
7995 }
7996
7997 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
7998 $can_retry = !$this->streaming_headers_sent
7999 && ($attempt + 1) < $max_attempts
8000 && $is_transient;
8001
8002 if (defined('WP_DEBUG') && WP_DEBUG) {
8003 error_log(sprintf(
8004 '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8005 $attempt + 1, $max_attempts, $http_code, $errno,
8006 $is_transient ? 'yes' : 'no',
8007 $can_retry ? 'Retrying.' : 'Giving up.'
8008 ));
8009 }
8010
8011 if (!$can_retry) {
8012 break;
8013 }
8014 }
8015
8016 if (!$errno && $http_code === 200) {
8017 if (!empty($full_response) && !empty($session_id)) {
8018 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
8019 }
8020 return true;
8021 }
8022
8023 return $this->mxchat_stream_emit_fallback(
8024 'openai',
8025 $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
8026 $session_id,
8027 $testing_data
8028 );
8029
8030 } catch (Exception $e) {
8031 return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
8032 }
8033 }
8034
8035 /**
8036 * Non-streaming chat completion against a custom OpenAI-compatible provider.
8037 * Returns string content on success, array['error'=>...] on failure.
8038 */
8039 private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
8040 $cfg = $this->mxchat_resolve_custom_provider();
8041 if (empty($cfg['base_url'])) {
8042 return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
8043 }
8044
8045 $bot_id = $this->get_current_bot_id(null);
8046 $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
8047 if (!is_array($conversation_history)) {
8048 $conversation_history = array();
8049 }
8050
8051 $messages = array(array(
8052 'role' => 'system',
8053 'content' => $system_prompt_instructions . ' ' . $relevant_content,
8054 ));
8055 foreach ($conversation_history as $message) {
8056 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8057 $role = $message['role'];
8058 if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
8059 if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
8060 $messages[] = array('role' => $role, 'content' => $message['content']);
8061 }
8062 }
8063
8064 $headers_assoc = array('Content-Type' => 'application/json');
8065 if (!empty($cfg['api_key'])) {
8066 if ($cfg['auth_scheme'] === 'api-key') {
8067 $headers_assoc['api-key'] = $cfg['api_key'];
8068 } else {
8069 $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
8070 }
8071 }
8072
8073 $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
8074 'headers' => $headers_assoc,
8075 'body' => wp_json_encode(array(
8076 'model' => $cfg['model'],
8077 'messages' => $messages,
8078 )),
8079 'timeout' => 120,
8080 ), 'openai');
8081
8082 if (is_wp_error($response)) {
8083 return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
8084 }
8085 $code = (int) wp_remote_retrieve_response_code($response);
8086 if ($code < 200 || $code >= 300) {
8087 return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
8088 }
8089 $body = json_decode(wp_remote_retrieve_body($response), true);
8090 if (isset($body['choices'][0]['message']['content'])) {
8091 return (string) $body['choices'][0]['message']['content'];
8092 }
8093 return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
8094 }
8095
8096 /**
8097 * Generate response using OpenAI Responses API with web search tool
8098 * This uses the newer Responses API which supports web search functionality
8099 */
8100 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
8101 try {
8102 $bot_id = $this->get_current_bot_id($session_id);
8103 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8104
8105 if (!is_array($conversation_history)) {
8106 $conversation_history = array();
8107 }
8108
8109 // Build the input for Responses API
8110 // The Responses API uses a different format - we need to construct the input properly
8111 $input_parts = [];
8112
8113 // Add system instructions as context
8114 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
8115
8116 // Build conversation as input items for Responses API
8117 foreach ($conversation_history as $message) {
8118 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8119 $role = $message['role'];
8120 if ($role === 'bot' || $role === 'agent') {
8121 $role = 'assistant';
8122 }
8123 if (!in_array($role, ['assistant', 'user'])) {
8124 $role = 'user';
8125 }
8126 $input_parts[] = [
8127 'type' => 'message',
8128 'role' => $role,
8129 'content' => $message['content']
8130 ];
8131 }
8132 }
8133
8134 // Build request body for Responses API
8135 $request_body = [
8136 'model' => $selected_model,
8137 'input' => $input_parts,
8138 'instructions' => $system_context,
8139 'stream' => $streaming
8140 ];
8141
8142 // Only add web search tool if web search is enabled in settings
8143 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8144 if ($web_search_enabled) {
8145 $request_body['tools'] = [
8146 ['type' => 'web_search']
8147 ];
8148 }
8149
8150 // Add reasoning effort for supported models
8151 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
8152 $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
8153 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_web, true)) {
8154 if ($selected_model === 'gpt-5.1-2025-11-13') {
8155 $request_body['reasoning'] = ['effort' => 'low'];
8156 } elseif ($selected_model === 'gpt-5.5') {
8157 $request_body['reasoning'] = ['effort' => 'low'];
8158 } elseif ($selected_model === 'gpt-5.4') {
8159 $request_body['reasoning'] = ['effort' => 'low'];
8160 }
8161 }
8162
8163 //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
8164
8165 if ($streaming) {
8166 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
8167 } else {
8168 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
8169 }
8170
8171 } catch (Exception $e) {
8172 //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
8173 return [
8174 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
8175 'error_code' => 'web_search_exception'
8176 ];
8177 }
8178 }
8179
8180 /**
8181 * Handle non-streaming web search response
8182 */
8183 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
8184 $request_body['stream'] = false;
8185
8186 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
8187 'headers' => array(
8188 'Authorization' => 'Bearer ' . $api_key,
8189 'Content-Type' => 'application/json'
8190 ),
8191 'body' => json_encode($request_body),
8192 'timeout' => 90
8193 ), 'openai');
8194
8195 if (is_wp_error($response)) {
8196 //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
8197 return [
8198 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
8199 'error_code' => 'web_search_connection_error'
8200 ];
8201 }
8202
8203 $response_code = wp_remote_retrieve_response_code($response);
8204 $response_body = wp_remote_retrieve_body($response);
8205
8206 //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
8207 //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
8208
8209 if ($response_code !== 200) {
8210 $error_data = json_decode($response_body, true);
8211 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
8212 return [
8213 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
8214 'error_code' => 'web_search_api_error'
8215 ];
8216 }
8217
8218 $result = json_decode($response_body, true);
8219
8220 if (json_last_error() !== JSON_ERROR_NONE) {
8221 return [
8222 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
8223 'error_code' => 'web_search_json_error'
8224 ];
8225 }
8226
8227 // Extract the response text and citations from Responses API format
8228 $output_text = '';
8229 $citations = [];
8230
8231 if (isset($result['output'])) {
8232 foreach ($result['output'] as $output_item) {
8233 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
8234 foreach ($output_item['content'] as $content_item) {
8235 if ($content_item['type'] === 'output_text') {
8236 $output_text .= $content_item['text'];
8237
8238 // Extract citations/annotations
8239 if (isset($content_item['annotations'])) {
8240 foreach ($content_item['annotations'] as $annotation) {
8241 if ($annotation['type'] === 'url_citation') {
8242 $citations[] = [
8243 'url' => $annotation['url'],
8244 'title' => $annotation['title'] ?? ''
8245 ];
8246 }
8247 }
8248 }
8249 }
8250 }
8251 }
8252 }
8253 }
8254
8255 // If we have citations, append them to the response
8256 if (!empty($citations)) {
8257 $output_text .= "\n\n**Sources:**\n";
8258 $seen_urls = [];
8259 foreach ($citations as $citation) {
8260 if (!in_array($citation['url'], $seen_urls)) {
8261 $seen_urls[] = $citation['url'];
8262 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
8263 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
8264 }
8265 }
8266 }
8267
8268 // Transcript save is handled by the main handler (mxchat_handle_chat_request)
8269 // which includes rag_context for the "sources" link in transcripts.
8270
8271 return $output_text;
8272 }
8273
8274 /**
8275 * Handle streaming web search response using Responses API
8276 */
8277 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
8278 $request_body['stream'] = true;
8279
8280 // Check if we can stream
8281 if (headers_sent() || !function_exists('curl_init')) {
8282 // Fallback to non-streaming
8283 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
8284 }
8285
8286 // Setup streaming headers
8287 $this->setup_streaming_headers();
8288
8289 $ch = curl_init();
8290 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
8291 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8292 curl_setopt($ch, CURLOPT_POST, true);
8293 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
8294 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8295 'Content-Type: application/json',
8296 'Authorization: Bearer ' . $api_key
8297 ));
8298 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8299 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
8300
8301 $full_response = '';
8302 $stream_started = false;
8303 $buffer = '';
8304 $citations = [];
8305
8306 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
8307 // Send testing data as first event if available
8308 if (!$stream_started && $testing_data !== null) {
8309 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8310 flush();
8311 $stream_started = true;
8312 }
8313
8314 $buffer .= $data;
8315 $lines = explode("\n", $buffer);
8316 $buffer = array_pop($lines);
8317
8318 foreach ($lines as $line) {
8319 if (trim($line) === '') continue;
8320 if (strpos($line, 'data: ') !== 0) continue;
8321
8322 $json_str = substr($line, 6);
8323
8324 if (trim($json_str) === '[DONE]') {
8325 // Append citations if we have any
8326 if (!empty($citations)) {
8327 $citation_text = "\n\n**Sources:**\n";
8328 $seen_urls = [];
8329 foreach ($citations as $citation) {
8330 if (!in_array($citation['url'], $seen_urls)) {
8331 $seen_urls[] = $citation['url'];
8332 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
8333 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
8334 }
8335 }
8336 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
8337 $full_response .= $citation_text;
8338 flush();
8339 }
8340 echo "data: [DONE]\n\n";
8341 flush();
8342 continue;
8343 }
8344
8345 $json = json_decode(trim($json_str), true);
8346 if (!$json) continue;
8347
8348 // Handle Responses API streaming events
8349 // The format is different from Chat Completions
8350 if (isset($json['type'])) {
8351 switch ($json['type']) {
8352 case 'response.output_text.delta':
8353 // Text content delta
8354 if (isset($json['delta'])) {
8355 $content = $json['delta'];
8356 $full_response .= $content;
8357 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8358 flush();
8359 }
8360 break;
8361
8362 case 'response.output_item.done':
8363 // Check for citations in completed items
8364 if (isset($json['item']['content'])) {
8365 foreach ($json['item']['content'] as $content_item) {
8366 if (isset($content_item['annotations'])) {
8367 foreach ($content_item['annotations'] as $annotation) {
8368 if ($annotation['type'] === 'url_citation') {
8369 $citations[] = [
8370 'url' => $annotation['url'],
8371 'title' => $annotation['title'] ?? ''
8372 ];
8373 }
8374 }
8375 }
8376 }
8377 }
8378 break;
8379 }
8380 }
8381 }
8382
8383 return strlen($data);
8384 });
8385
8386 $response = curl_exec($ch);
8387 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8388
8389 if (curl_errno($ch) || $http_code !== 200) {
8390 $curl_error = curl_error($ch);
8391 curl_close($ch);
8392
8393 //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
8394
8395 return $this->mxchat_stream_emit_fallback(
8396 'web_search',
8397 $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
8398 $session_id,
8399 $testing_data
8400 );
8401 }
8402
8403 curl_close($ch);
8404
8405 // Save the complete response with RAG context so the "sources" link
8406 // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
8407 if (!empty($full_response) && !empty($session_id)) {
8408 $rag_context_for_storage = null;
8409 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8410 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8411
8412 if ($has_rag_data || $has_action_data) {
8413 $rag_context_for_storage = [];
8414
8415 if ($has_rag_data) {
8416 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8417 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8418 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8419 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8420 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8421 }
8422
8423 if ($has_action_data) {
8424 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8425 }
8426 }
8427 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8428 }
8429
8430 return true;
8431 }
8432
8433 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8434 try {
8435 // Get bot ID from session or request
8436 $bot_id = $this->get_current_bot_id($session_id);
8437
8438 // Get system prompt instructions using centralized function
8439 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8440 // Ensure conversation_history is an array
8441 if (!is_array($conversation_history)) {
8442 $conversation_history = array();
8443 }
8444
8445 // Clean and validate conversation history
8446 foreach ($conversation_history as &$message) {
8447 // Convert bot and agent roles to assistant
8448 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8449 $message['role'] = 'assistant';
8450 }
8451
8452 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8453 if (!in_array($message['role'], ['assistant', 'user'])) {
8454 $message['role'] = 'user';
8455 }
8456
8457 // Ensure content field exists
8458 if (!isset($message['content']) || empty($message['content'])) {
8459 $message['content'] = '';
8460 }
8461
8462 // Remove any unsupported fields
8463 $message = array_intersect_key($message, array_flip(['role', 'content']));
8464 }
8465
8466 // Add relevant content as the latest user message
8467 $conversation_history[] = [
8468 'role' => 'user',
8469 'content' => $relevant_content
8470 ];
8471
8472 // Prepare the request body with stream: true
8473 $payload = [
8474 'model' => $selected_model,
8475 'messages' => $conversation_history,
8476 'max_tokens' => 1000,
8477 'temperature' => 0.8,
8478 'system' => $system_prompt_instructions,
8479 'stream' => true
8480 ];
8481 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
8482 $body = json_encode($payload);
8483
8484 // Check if we can actually stream (headers not sent, etc.)
8485 if (headers_sent() || !function_exists('curl_init')) {
8486 // Fallback to regular response with testing data
8487 //error_log("MxChat: Streaming not possible, falling back to regular response");
8488 $regular_response = $this->mxchat_generate_response_claude(
8489 $selected_model,
8490 $claude_api_key,
8491 array_slice($conversation_history, 0, -1), // Remove the added content
8492 $relevant_content
8493 );
8494
8495 // Save bot response to transcript
8496 if (!empty($regular_response) && !empty($session_id)) {
8497 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8498 }
8499
8500 // Return as JSON with testing data
8501 $response_data = [
8502 'text' => $regular_response,
8503 'html' => '',
8504 'session_id' => $session_id
8505 ];
8506
8507 if ($testing_data !== null) {
8508 $response_data['testing_data'] = $testing_data;
8509 //error_log("MxChat Testing: Added testing data to Claude fallback response");
8510 }
8511
8512 // Clear any streaming headers and send JSON
8513 if (headers_sent() === false) {
8514 header('Content-Type: application/json');
8515 }
8516 echo json_encode($response_data);
8517 return true; // Indicate we handled the response
8518 }
8519
8520 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
8521
8522 $captured_status_code = 0;
8523 $captured_body_pre_stream = '';
8524 $full_response = '';
8525 $stream_started = false;
8526 $buffer = '';
8527 $errno = 0;
8528 $http_code = 0;
8529 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8530 $backoff_ms = array(0, 750, 2000);
8531
8532 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8533 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8534 usleep($backoff_ms[$attempt] * 1000);
8535 }
8536
8537 $captured_status_code = 0;
8538 $captured_body_pre_stream = '';
8539 $full_response = '';
8540 $stream_started = false;
8541 $buffer = '';
8542
8543 $ch = curl_init();
8544 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
8545 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8546 curl_setopt($ch, CURLOPT_POST, true);
8547 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8548 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8549 'Content-Type: application/json',
8550 'x-api-key: ' . $claude_api_key,
8551 'anthropic-version: 2023-06-01'
8552 ));
8553 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8554 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8555
8556 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8557 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8558 $captured_status_code = (int) $m[1];
8559 }
8560 return strlen($header);
8561 });
8562
8563 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8564 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8565 $captured_body_pre_stream .= $data;
8566 return strlen($data);
8567 }
8568
8569 if (!$this->streaming_headers_sent) {
8570 $this->setup_streaming_headers();
8571 }
8572
8573 if (!$stream_started && $testing_data !== null) {
8574 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8575 flush();
8576 $stream_started = true;
8577 }
8578
8579 $buffer .= $data;
8580 $lines = explode("\n", $buffer);
8581 $buffer = array_pop($lines);
8582
8583 foreach ($lines as $line) {
8584 if (trim($line) === '') {
8585 continue;
8586 }
8587
8588 if (strpos($line, 'event: ') === 0) {
8589 continue;
8590 }
8591
8592 if (strpos($line, 'data: ') === 0) {
8593 $json_str = substr($line, 6);
8594
8595 $json = json_decode(trim($json_str), true);
8596 if (json_last_error() !== JSON_ERROR_NONE) {
8597 continue;
8598 }
8599
8600 if (isset($json['type'])) {
8601 switch ($json['type']) {
8602 case 'content_block_delta':
8603 if (isset($json['delta']['text'])) {
8604 $content = $json['delta']['text'];
8605 $full_response .= $content;
8606 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8607 flush();
8608 }
8609 break;
8610
8611 case 'message_stop':
8612 echo "data: [DONE]\n\n";
8613 flush();
8614 break;
8615
8616 case 'error':
8617 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
8618 flush();
8619 break;
8620 }
8621 }
8622 }
8623 }
8624
8625 return strlen($data);
8626 });
8627
8628 $response = curl_exec($ch);
8629 $errno = curl_errno($ch);
8630 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8631 curl_close($ch);
8632
8633 if (!$errno && $http_code === 200) {
8634 break;
8635 }
8636
8637 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
8638 $can_retry = !$this->streaming_headers_sent
8639 && ($attempt + 1) < $max_attempts
8640 && $is_transient;
8641
8642 if (defined('WP_DEBUG') && WP_DEBUG) {
8643 error_log(sprintf(
8644 '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8645 $attempt + 1, $max_attempts, $http_code, $errno,
8646 $is_transient ? 'yes' : 'no',
8647 $can_retry ? 'Retrying.' : 'Giving up.'
8648 ));
8649 }
8650
8651 if (!$can_retry) {
8652 break;
8653 }
8654 }
8655
8656 if ($errno || $http_code !== 200) {
8657 return $this->mxchat_stream_emit_fallback(
8658 'anthropic',
8659 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content),
8660 $session_id,
8661 $testing_data
8662 );
8663 }
8664
8665 // Save the complete response to maintain chat persistence
8666 if (!empty($full_response) && !empty($session_id)) {
8667 // Prepare RAG context for streaming response
8668 $rag_context_for_storage = null;
8669 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8670 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8671
8672 if ($has_rag_data || $has_action_data) {
8673 $rag_context_for_storage = [];
8674
8675 if ($has_rag_data) {
8676 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8677 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8678 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8679 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8680 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8681 }
8682
8683 if ($has_action_data) {
8684 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8685 }
8686 }
8687 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8688 }
8689
8690 return true; // Indicate streaming completed successfully
8691
8692 } catch (Exception $e) {
8693 return $this->mxchat_stream_emit_fallback(
8694 'anthropic',
8695 $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content),
8696 $session_id,
8697 $testing_data
8698 );
8699 }
8700 }
8701 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8702 try {
8703 // Get bot ID from session or request
8704 $bot_id = $this->get_current_bot_id($session_id);
8705
8706 // Get system prompt instructions using centralized function
8707 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8708
8709 // Ensure conversation_history is an array
8710 if (!is_array($conversation_history)) {
8711 $conversation_history = array();
8712 }
8713
8714 // Format conversation history for X.AI (same as OpenAI format)
8715 $formatted_conversation = array();
8716
8717 $formatted_conversation[] = array(
8718 'role' => 'system',
8719 'content' => $system_prompt_instructions . " " . $relevant_content
8720 );
8721
8722 foreach ($conversation_history as $message) {
8723 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8724 $role = $message['role'];
8725 if ($role === 'bot' || $role === 'agent') {
8726 $role = 'assistant';
8727 }
8728 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8729 $role = 'user';
8730 }
8731 $formatted_conversation[] = array(
8732 'role' => $role,
8733 'content' => $message['content']
8734 );
8735 }
8736 }
8737
8738 // Check if we can actually stream
8739 if (headers_sent() || !function_exists('curl_init')) {
8740 // Fallback to regular response with testing data
8741 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
8742 $regular_response = $this->mxchat_generate_response_xai(
8743 $selected_model,
8744 $xai_api_key,
8745 $conversation_history,
8746 $relevant_content
8747 );
8748
8749 // Save bot response to transcript
8750 if (!empty($regular_response) && !empty($session_id)) {
8751 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8752 }
8753
8754 $response_data = [
8755 'text' => $regular_response,
8756 'html' => '',
8757 'session_id' => $session_id
8758 ];
8759
8760 if ($testing_data !== null) {
8761 $response_data['testing_data'] = $testing_data;
8762 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
8763 }
8764
8765 header('Content-Type: application/json');
8766 echo json_encode($response_data);
8767 return true;
8768 }
8769
8770 // Prepare the request body with stream: true
8771 $body = json_encode([
8772 'model' => $selected_model,
8773 'messages' => $formatted_conversation,
8774 'temperature' => 0.8,
8775 'stream' => true
8776 ]);
8777
8778 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
8779
8780 $captured_status_code = 0;
8781 $captured_body_pre_stream = '';
8782 $full_response = '';
8783 $stream_started = false;
8784 $buffer = '';
8785 $errno = 0;
8786 $http_code = 0;
8787 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
8788 $backoff_ms = array(0, 750, 2000);
8789
8790 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
8791 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
8792 usleep($backoff_ms[$attempt] * 1000);
8793 }
8794
8795 $captured_status_code = 0;
8796 $captured_body_pre_stream = '';
8797 $full_response = '';
8798 $stream_started = false;
8799 $buffer = '';
8800
8801 $ch = curl_init();
8802 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
8803 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
8804 curl_setopt($ch, CURLOPT_POST, true);
8805 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
8806 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
8807 'Content-Type: application/json',
8808 'Authorization: Bearer ' . $xai_api_key
8809 ));
8810 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8811 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
8812
8813 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
8814 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
8815 $captured_status_code = (int) $m[1];
8816 }
8817 return strlen($header);
8818 });
8819
8820 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
8821 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
8822 $captured_body_pre_stream .= $data;
8823 return strlen($data);
8824 }
8825
8826 if (!$this->streaming_headers_sent) {
8827 $this->setup_streaming_headers();
8828 }
8829
8830 if (!$stream_started && $testing_data !== null) {
8831 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
8832 flush();
8833 $stream_started = true;
8834 }
8835
8836 $buffer .= $data;
8837 $lines = explode("\n", $buffer);
8838 $buffer = array_pop($lines);
8839
8840 foreach ($lines as $line) {
8841 if (trim($line) === '') {
8842 continue;
8843 }
8844 if (strpos($line, 'data: ') !== 0) {
8845 continue;
8846 }
8847
8848 $json_str = substr($line, 6);
8849
8850 if (trim($json_str) === '[DONE]') {
8851 echo "data: [DONE]\n\n";
8852 flush();
8853 continue;
8854 }
8855
8856 $json = json_decode(trim($json_str), true);
8857 if ($json && isset($json['choices'][0]['delta']['content'])) {
8858 $content = $json['choices'][0]['delta']['content'];
8859 $full_response .= $content;
8860 echo "data: " . json_encode(['content' => $content]) . "\n\n";
8861 flush();
8862 }
8863 }
8864
8865 return strlen($data);
8866 });
8867
8868 $response = curl_exec($ch);
8869 $errno = curl_errno($ch);
8870 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
8871 curl_close($ch);
8872
8873 if (!$errno && $http_code === 200) {
8874 break;
8875 }
8876
8877 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
8878 $can_retry = !$this->streaming_headers_sent
8879 && ($attempt + 1) < $max_attempts
8880 && $is_transient;
8881
8882 if (defined('WP_DEBUG') && WP_DEBUG) {
8883 error_log(sprintf(
8884 '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
8885 $attempt + 1, $max_attempts, $http_code, $errno,
8886 $is_transient ? 'yes' : 'no',
8887 $can_retry ? 'Retrying.' : 'Giving up.'
8888 ));
8889 }
8890
8891 if (!$can_retry) {
8892 break;
8893 }
8894 }
8895
8896 if ($errno || $http_code !== 200) {
8897 return $this->mxchat_stream_emit_fallback(
8898 'xai',
8899 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
8900 $session_id,
8901 $testing_data
8902 );
8903 }
8904
8905 // Save the complete response to maintain chat persistence
8906 if (!empty($full_response) && !empty($session_id)) {
8907 // Prepare RAG context for streaming response
8908 $rag_context_for_storage = null;
8909 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
8910 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
8911
8912 if ($has_rag_data || $has_action_data) {
8913 $rag_context_for_storage = [];
8914
8915 if ($has_rag_data) {
8916 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
8917 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
8918 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
8919 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
8920 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
8921 }
8922
8923 if ($has_action_data) {
8924 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
8925 }
8926 }
8927 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
8928 }
8929
8930 return true; // Indicate streaming completed successfully
8931
8932 } catch (Exception $e) {
8933 return $this->mxchat_stream_emit_fallback(
8934 'xai',
8935 $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
8936 $session_id,
8937 $testing_data
8938 );
8939 }
8940 }
8941 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8942 try {
8943 // Get bot ID from session or request
8944 $bot_id = $this->get_current_bot_id($session_id);
8945
8946 // Get system prompt instructions using centralized function
8947 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8948
8949 // Ensure conversation_history is an array
8950 if (!is_array($conversation_history)) {
8951 $conversation_history = array();
8952 }
8953
8954 // Format conversation history for DeepSeek
8955 $formatted_conversation = array();
8956
8957 $formatted_conversation[] = array(
8958 'role' => 'system',
8959 'content' => $system_prompt_instructions . " " . $relevant_content
8960 );
8961
8962 foreach ($conversation_history as $message) {
8963 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8964 $role = $message['role'];
8965 if ($role === 'bot' || $role === 'agent') {
8966 $role = 'assistant';
8967 }
8968 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8969 $role = 'user';
8970 }
8971 $formatted_conversation[] = array(
8972 'role' => $role,
8973 'content' => $message['content']
8974 );
8975 }
8976 }
8977
8978 // Check if we can actually stream
8979 if (headers_sent() || !function_exists('curl_init')) {
8980 // Fallback to regular response with testing data
8981 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
8982 $regular_response = $this->mxchat_generate_response_deepseek(
8983 $selected_model,
8984 $deepseek_api_key,
8985 $conversation_history,
8986 $relevant_content
8987 );
8988
8989 // Save bot response to transcript
8990 if (!empty($regular_response) && !empty($session_id)) {
8991 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8992 }
8993
8994 $response_data = [
8995 'text' => $regular_response,
8996 'html' => '',
8997 'session_id' => $session_id
8998 ];
8999
9000 if ($testing_data !== null) {
9001 $response_data['testing_data'] = $testing_data;
9002 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
9003 }
9004
9005 header('Content-Type: application/json');
9006 echo json_encode($response_data);
9007 return true;
9008 }
9009
9010 // Prepare the request body with stream: true
9011 $body = json_encode([
9012 'model' => $selected_model,
9013 'messages' => $formatted_conversation,
9014 'temperature' => 0.8,
9015 'stream' => true
9016 ]);
9017
9018 // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9019
9020 $captured_status_code = 0;
9021 $captured_body_pre_stream = '';
9022 $full_response = '';
9023 $stream_started = false;
9024 $buffer = '';
9025 $errno = 0;
9026 $http_code = 0;
9027 $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9028 $backoff_ms = array(0, 750, 2000);
9029
9030 for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9031 if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9032 usleep($backoff_ms[$attempt] * 1000);
9033 }
9034
9035 $captured_status_code = 0;
9036 $captured_body_pre_stream = '';
9037 $full_response = '';
9038 $stream_started = false;
9039 $buffer = '';
9040
9041 $ch = curl_init();
9042 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
9043 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9044 curl_setopt($ch, CURLOPT_POST, true);
9045 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9046 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9047 'Content-Type: application/json',
9048 'Authorization: Bearer ' . $deepseek_api_key
9049 ));
9050 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9051 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9052
9053 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9054 if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9055 $captured_status_code = (int) $m[1];
9056 }
9057 return strlen($header);
9058 });
9059
9060 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9061 if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9062 $captured_body_pre_stream .= $data;
9063 return strlen($data);
9064 }
9065
9066 if (!$this->streaming_headers_sent) {
9067 $this->setup_streaming_headers();
9068 }
9069
9070 if (!$stream_started && $testing_data !== null) {
9071 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9072 flush();
9073 $stream_started = true;
9074 }
9075
9076 $buffer .= $data;
9077 $lines = explode("\n", $buffer);
9078 $buffer = array_pop($lines);
9079
9080 foreach ($lines as $line) {
9081 if (trim($line) === '') {
9082 continue;
9083 }
9084 if (strpos($line, 'data: ') !== 0) {
9085 continue;
9086 }
9087
9088 $json_str = substr($line, 6);
9089
9090 if (trim($json_str) === '[DONE]') {
9091 echo "data: [DONE]\n\n";
9092 flush();
9093 continue;
9094 }
9095
9096 $json = json_decode(trim($json_str), true);
9097 if ($json && isset($json['choices'][0]['delta']['content'])) {
9098 $content = $json['choices'][0]['delta']['content'];
9099 $full_response .= $content;
9100 echo "data: " . json_encode(['content' => $content]) . "\n\n";
9101 flush();
9102 }
9103 }
9104
9105 return strlen($data);
9106 });
9107
9108 $response = curl_exec($ch);
9109 $errno = curl_errno($ch);
9110 $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9111 curl_close($ch);
9112
9113 if (!$errno && $http_code === 200) {
9114 break;
9115 }
9116
9117 $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9118 $can_retry = !$this->streaming_headers_sent
9119 && ($attempt + 1) < $max_attempts
9120 && $is_transient;
9121
9122 if (defined('WP_DEBUG') && WP_DEBUG) {
9123 error_log(sprintf(
9124 '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9125 $attempt + 1, $max_attempts, $http_code, $errno,
9126 $is_transient ? 'yes' : 'no',
9127 $can_retry ? 'Retrying.' : 'Giving up.'
9128 ));
9129 }
9130
9131 if (!$can_retry) {
9132 break;
9133 }
9134 }
9135
9136 if ($errno || $http_code !== 200) {
9137 return $this->mxchat_stream_emit_fallback(
9138 'openai',
9139 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
9140 $session_id,
9141 $testing_data
9142 );
9143 }
9144
9145 // Save the complete response to maintain chat persistence
9146 if (!empty($full_response) && !empty($session_id)) {
9147 // Prepare RAG context for streaming response
9148 $rag_context_for_storage = null;
9149 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9150 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9151
9152 if ($has_rag_data || $has_action_data) {
9153 $rag_context_for_storage = [];
9154
9155 if ($has_rag_data) {
9156 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9157 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9158 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9159 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9160 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9161 }
9162
9163 if ($has_action_data) {
9164 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9165 }
9166 }
9167 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9168 }
9169
9170 return true; // Indicate streaming completed successfully
9171
9172 } catch (Exception $e) {
9173 return $this->mxchat_stream_emit_fallback(
9174 'openai',
9175 $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
9176 $session_id,
9177 $testing_data
9178 );
9179 }
9180 }
9181
9182
9183 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
9184 try {
9185 if (!is_array($conversation_history)) {
9186 $conversation_history = array();
9187 }
9188
9189 $bot_id = $this->get_current_bot_id('');
9190 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9191
9192 $formatted_conversation = array();
9193
9194 $formatted_conversation[] = array(
9195 'role' => 'system',
9196 'content' => $system_prompt_instructions . " " . $relevant_content
9197 );
9198
9199 foreach ($conversation_history as $message) {
9200 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9201 $role = $message['role'];
9202
9203 if ($role === 'bot' || $role === 'agent') {
9204 $role = 'assistant';
9205 }
9206 if (!in_array($role, ['system', 'assistant', 'user'])) {
9207 $role = 'user';
9208 }
9209
9210 $formatted_conversation[] = array(
9211 'role' => $role,
9212 'content' => $message['content']
9213 );
9214 }
9215 }
9216
9217 $body = json_encode([
9218 'model' => $selected_model,
9219 'messages' => $formatted_conversation,
9220 'temperature' => 1,
9221 ]);
9222
9223 $args = [
9224 'body' => $body,
9225 'headers' => [
9226 'Content-Type' => 'application/json',
9227 'Authorization' => 'Bearer ' . $openrouter_api_key,
9228 'HTTP-Referer' => home_url(),
9229 'X-Title' => get_bloginfo('name'),
9230 ],
9231 'timeout' => 60,
9232 'redirection' => 5,
9233 'blocking' => true,
9234 'httpversion' => '1.0',
9235 'sslverify' => true,
9236 ];
9237
9238 $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
9239
9240 if (is_wp_error($response)) {
9241 $error_message = $response->get_error_message();
9242 return [
9243 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
9244 'error_code' => 'openrouter_connection_error',
9245 'provider' => 'openrouter'
9246 ];
9247 }
9248
9249 $status_code = wp_remote_retrieve_response_code($response);
9250 if ($status_code !== 200) {
9251 $response_body = wp_remote_retrieve_body($response);
9252 $decoded_response = json_decode($response_body, true);
9253
9254 $error_message = isset($decoded_response['error']['message'])
9255 ? $decoded_response['error']['message']
9256 : 'HTTP Error ' . $status_code;
9257
9258 return [
9259 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
9260 'error_code' => 'openrouter_api_error',
9261 'provider' => 'openrouter',
9262 'status_code' => $status_code
9263 ];
9264 }
9265
9266 $response_body = wp_remote_retrieve_body($response);
9267 $decoded_response = json_decode($response_body, true);
9268
9269 if (isset($decoded_response['choices'][0]['message']['content'])) {
9270 return trim($decoded_response['choices'][0]['message']['content']);
9271 } else {
9272 return [
9273 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
9274 'error_code' => 'openrouter_response_format_error',
9275 'provider' => 'openrouter'
9276 ];
9277 }
9278 } catch (Exception $e) {
9279 return [
9280 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
9281 'error_code' => 'openrouter_exception',
9282 'provider' => 'openrouter'
9283 ];
9284 }
9285 }
9286 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
9287
9288 // Get bot ID from session or request
9289 $bot_id = $this->get_current_bot_id($session_id);
9290
9291 // Get system prompt instructions using centralized function
9292 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9293
9294 // Clean and validate conversation history
9295 foreach ($conversation_history as &$message) {
9296 // Convert bot and agent roles to assistant
9297 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
9298 $message['role'] = 'assistant';
9299 }
9300
9301 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
9302 if (!in_array($message['role'], ['assistant', 'user'])) {
9303 $message['role'] = 'user';
9304 }
9305
9306 // Ensure content field exists
9307 if (!isset($message['content']) || empty($message['content'])) {
9308 $message['content'] = '';
9309 }
9310
9311 // Remove any unsupported fields
9312 $message = array_intersect_key($message, array_flip(['role', 'content']));
9313 }
9314
9315 // Add relevant content as the latest user message
9316 $conversation_history[] = [
9317 'role' => 'user',
9318 'content' => $relevant_content
9319 ];
9320
9321 // Build request body
9322 $payload = [
9323 'model' => $selected_model,
9324 'max_tokens' => 1000,
9325 'temperature' => 0.8,
9326 'messages' => $conversation_history,
9327 'system' => $system_prompt_instructions
9328 ];
9329 if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
9330 $body = json_encode($payload);
9331
9332 // Set up API request
9333 $args = [
9334 'body' => $body,
9335 'headers' => [
9336 'Content-Type' => 'application/json',
9337 'x-api-key' => $claude_api_key,
9338 'anthropic-version' => '2023-06-01'
9339 ],
9340 'timeout' => 60,
9341 'redirection' => 5,
9342 'blocking' => true,
9343 'httpversion' => '1.0',
9344 'sslverify' => true,
9345 ];
9346
9347 // Make API request
9348 $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
9349
9350 // Check for WordPress errors
9351 if (is_wp_error($response)) {
9352 //error_log("Claude API request error: " . $response->get_error_message());
9353 return "Sorry, there was an error connecting to the API.";
9354 }
9355
9356 // Check HTTP response code
9357 $http_code = wp_remote_retrieve_response_code($response);
9358 if ($http_code !== 200) {
9359 $error_body = wp_remote_retrieve_body($response);
9360 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
9361
9362 // Try to extract error message from response
9363 $error_data = json_decode($error_body, true);
9364 $error_message = isset($error_data['error']['message']) ?
9365 $error_data['error']['message'] :
9366 "HTTP error " . $http_code;
9367
9368 return "Sorry, the API returned an error: " . $error_message;
9369 }
9370
9371 // Parse response
9372 $response_body = json_decode(wp_remote_retrieve_body($response), true);
9373
9374 // Check for JSON decode errors
9375 if (json_last_error() !== JSON_ERROR_NONE) {
9376 //error_log("Claude API JSON decode error: " . json_last_error_msg());
9377 return "Sorry, there was an error processing the API response.";
9378 }
9379
9380 // Extract and validate response content
9381 if (isset($response_body['content']) &&
9382 is_array($response_body['content']) &&
9383 !empty($response_body['content']) &&
9384 isset($response_body['content'][0]['text'])) {
9385 return trim($response_body['content'][0]['text']);
9386 }
9387
9388 // Log unexpected response format
9389 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
9390 return "Sorry, I received an unexpected response format from the API.";
9391 }
9392 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
9393 try {
9394 // Ensure conversation_history is an array
9395 if (!is_array($conversation_history)) {
9396 $conversation_history = array();
9397 }
9398
9399 // Get bot ID from session or request
9400 $bot_id = $this->get_current_bot_id('');
9401
9402 // Get system prompt instructions using centralized function
9403 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9404
9405 // Create a new array for the formatted conversation
9406 $formatted_conversation = array();
9407
9408 // Add system message first
9409 $formatted_conversation[] = array(
9410 'role' => 'system',
9411 'content' => $system_prompt_instructions . " " . $relevant_content
9412 );
9413
9414 // Add the rest of the conversation history
9415 foreach ($conversation_history as $message) {
9416 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9417 $role = $message['role'];
9418
9419 // Convert roles to supported format
9420 if ($role === 'bot' || $role === 'agent') {
9421 $role = 'assistant';
9422 }
9423 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9424 $role = 'user';
9425 }
9426
9427 $formatted_conversation[] = array(
9428 'role' => $role,
9429 'content' => $message['content']
9430 );
9431 }
9432 }
9433
9434 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
9435 $is_gpt5_model = (
9436 strpos($selected_model, 'gpt-5') === 0 ||
9437 $selected_model === 'gpt-5.2' ||
9438 $selected_model === 'gpt-5.1-2025-11-13' ||
9439 $selected_model === 'gpt-5' ||
9440 $selected_model === 'gpt-5-mini' ||
9441 $selected_model === 'gpt-5-nano'
9442 );
9443
9444 // Build request body with optimal settings for fast responses
9445 $request_body = [
9446 'model' => $selected_model,
9447 'messages' => $formatted_conversation,
9448 'temperature' => 1,
9449 'stream' => false
9450 ];
9451
9452 // Add reasoning_effort only for GPT-5 models that support it
9453 // These chat models don't support reasoning_effort parameter
9454 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
9455 if ($is_gpt5_model && !in_array($selected_model, $no_reasoning_models, true)) {
9456 // GPT-5.1 uses 'low' instead of 'minimal'
9457 if ($selected_model === 'gpt-5.1-2025-11-13') {
9458 $request_body['reasoning_effort'] = 'low';
9459 } elseif ($selected_model === 'gpt-5.5') {
9460 $request_body['reasoning_effort'] = 'none';
9461 } elseif ($selected_model === 'gpt-5.4') {
9462 $request_body['reasoning_effort'] = 'none';
9463 } else {
9464 $request_body['reasoning_effort'] = 'minimal';
9465 }
9466 }
9467
9468 $body = json_encode($request_body);
9469
9470 $args = [
9471 'body' => $body,
9472 'headers' => [
9473 'Content-Type' => 'application/json',
9474 'Authorization' => 'Bearer ' . $api_key,
9475 ],
9476 'timeout' => 60,
9477 'redirection' => 5,
9478 'blocking' => true,
9479 'httpversion' => '1.0',
9480 'sslverify' => true,
9481 ];
9482
9483 $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
9484
9485 if (is_wp_error($response)) {
9486 $error_message = $response->get_error_message();
9487 return [
9488 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
9489 'error_code' => 'openai_connection_error',
9490 'provider' => 'openai'
9491 ];
9492 }
9493
9494 $status_code = wp_remote_retrieve_response_code($response);
9495 if ($status_code !== 200) {
9496 $response_body = wp_remote_retrieve_body($response);
9497 $decoded_response = json_decode($response_body, true);
9498
9499 $error_message = isset($decoded_response['error']['message'])
9500 ? $decoded_response['error']['message']
9501 : 'HTTP Error ' . $status_code;
9502
9503 $error_type = isset($decoded_response['error']['type'])
9504 ? $decoded_response['error']['type']
9505 : 'unknown';
9506
9507 // Handle specific error types
9508 switch ($error_type) {
9509 case 'invalid_request_error':
9510 if (strpos($error_message, 'API key') !== false) {
9511 return [
9512 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
9513 'error_code' => 'openai_invalid_api_key',
9514 'provider' => 'openai'
9515 ];
9516 }
9517 break;
9518
9519 case 'authentication_error':
9520 return [
9521 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
9522 'error_code' => 'openai_auth_error',
9523 'provider' => 'openai'
9524 ];
9525
9526 case 'rate_limit_exceeded':
9527 return [
9528 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
9529 'error_code' => 'openai_rate_limit',
9530 'provider' => 'openai'
9531 ];
9532
9533 case 'quota_exceeded':
9534 return [
9535 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
9536 'error_code' => 'openai_quota_exceeded',
9537 'provider' => 'openai'
9538 ];
9539 }
9540
9541 // Generic error fallback
9542 return [
9543 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
9544 'error_code' => 'openai_api_error',
9545 'provider' => 'openai',
9546 'status_code' => $status_code
9547 ];
9548 }
9549
9550 $response_body = wp_remote_retrieve_body($response);
9551 $decoded_response = json_decode($response_body, true);
9552
9553 if (isset($decoded_response['choices'][0]['message']['content'])) {
9554 return trim($decoded_response['choices'][0]['message']['content']);
9555 } else {
9556 return [
9557 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
9558 'error_code' => 'openai_response_format_error',
9559 'provider' => 'openai'
9560 ];
9561 }
9562 } catch (Exception $e) {
9563 return [
9564 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
9565 'error_code' => 'openai_exception',
9566 'provider' => 'openai'
9567 ];
9568 }
9569 }
9570
9571 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
9572 try {
9573 // Get bot ID from session or request
9574 $bot_id = $this->get_current_bot_id($session_id);
9575
9576 // Get system prompt instructions using centralized function
9577 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9578
9579 // Add system prompt to relevant content
9580 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9581
9582 // Prepend system instructions to the conversation history
9583 array_unshift($conversation_history, [
9584 'role' => 'system',
9585 'content' => "Here are your instructions: " . $content_with_instructions
9586 ]);
9587
9588 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
9589 foreach ($conversation_history as &$message) {
9590 if ($message['role'] === 'bot') {
9591 $message['role'] = 'assistant';
9592 } elseif ($message['role'] === 'agent') {
9593 // Tag the message as coming from a live agent
9594 $message['role'] = 'assistant';
9595 if (!isset($message['metadata'])) {
9596 $message['metadata'] = ['source' => 'live_agent'];
9597 }
9598 }
9599
9600 // Ensure all roles are valid
9601 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
9602 $message['role'] = 'user'; // Default to 'user'
9603 }
9604 }
9605
9606 // Build the request body
9607 $body = json_encode([
9608 'model' => $selected_model,
9609 'messages' => $conversation_history,
9610 'temperature' => 0.8,
9611 'stream' => false
9612 ]);
9613
9614 // Set up the API request
9615 $args = [
9616 'body' => $body,
9617 'headers' => [
9618 'Content-Type' => 'application/json',
9619 'Authorization' => 'Bearer ' . $xai_api_key,
9620 ],
9621 'timeout' => 60,
9622 'redirection' => 5,
9623 'blocking' => true,
9624 'httpversion' => '1.0',
9625 'sslverify' => true,
9626 ];
9627
9628 // Make the API request
9629 $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
9630
9631 // Process the response
9632 if (is_wp_error($response)) {
9633 $error_message = $response->get_error_message();
9634 //error_log('X.AI API Error: ' . $error_message);
9635 return [
9636 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
9637 'error_code' => 'xai_connection_error',
9638 'provider' => 'xai'
9639 ];
9640 }
9641
9642 $status_code = wp_remote_retrieve_response_code($response);
9643 if ($status_code !== 200) {
9644 $response_body = wp_remote_retrieve_body($response);
9645 $decoded_response = json_decode($response_body, true);
9646
9647 // Log the full response for debugging
9648 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
9649
9650 // Extract error message from X.AI's specific format
9651 $error_message = '';
9652
9653 // Check for direct error string (as seen in your logs)
9654 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
9655 $error_message = $decoded_response['error'];
9656 }
9657 // Check for nested error object (OpenAI style)
9658 elseif (isset($decoded_response['error']['message'])) {
9659 $error_message = $decoded_response['error']['message'];
9660 }
9661 // Check for top-level message
9662 elseif (isset($decoded_response['message'])) {
9663 $error_message = $decoded_response['message'];
9664 }
9665 // Fallback
9666 else {
9667 $error_message = 'HTTP Error ' . $status_code;
9668 }
9669
9670 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
9671
9672 // Check for API key errors using string matching
9673 if (stripos($error_message, 'api key') !== false ||
9674 stripos($error_message, 'incorrect api key') !== false ||
9675 stripos($error_message, 'invalid api key') !== false) {
9676 return [
9677 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
9678 'error_code' => 'xai_invalid_api_key',
9679 'provider' => 'xai'
9680 ];
9681 }
9682
9683 // Authentication errors
9684 if ($status_code === 401 || $status_code === 403 ||
9685 stripos($error_message, 'auth') !== false) {
9686 return [
9687 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
9688 'error_code' => 'xai_auth_error',
9689 'provider' => 'xai'
9690 ];
9691 }
9692
9693 // Model errors
9694 if (stripos($error_message, 'model') !== false) {
9695 return [
9696 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
9697 'error_code' => 'xai_invalid_model',
9698 'provider' => 'xai'
9699 ];
9700 }
9701
9702 // Rate limit errors
9703 if ($status_code === 429 ||
9704 stripos($error_message, 'rate') !== false ||
9705 stripos($error_message, 'limit') !== false) {
9706 return [
9707 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
9708 'error_code' => 'xai_rate_limit',
9709 'provider' => 'xai'
9710 ];
9711 }
9712
9713 // Quota errors
9714 if (stripos($error_message, 'quota') !== false ||
9715 stripos($error_message, 'billing') !== false) {
9716 return [
9717 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
9718 'error_code' => 'xai_quota_exceeded',
9719 'provider' => 'xai'
9720 ];
9721 }
9722
9723 // Server errors
9724 if ($status_code >= 500) {
9725 return [
9726 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
9727 'error_code' => 'xai_service_unavailable',
9728 'provider' => 'xai'
9729 ];
9730 }
9731
9732 // Generic error fallback with the actual error message
9733 return [
9734 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
9735 'error_code' => 'xai_api_error',
9736 'provider' => 'xai',
9737 'status_code' => $status_code
9738 ];
9739 }
9740
9741 $response_body = wp_remote_retrieve_body($response);
9742 $decoded_response = json_decode($response_body, true);
9743
9744 if (isset($decoded_response['choices'][0]['message']['content'])) {
9745 return trim($decoded_response['choices'][0]['message']['content']);
9746 } else {
9747 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
9748 return [
9749 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
9750 'error_code' => 'xai_response_format_error',
9751 'provider' => 'xai'
9752 ];
9753 }
9754 } catch (Exception $e) {
9755 //error_log('X.AI Exception: ' . $e->getMessage());
9756 return [
9757 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
9758 'error_code' => 'xai_exception',
9759 'provider' => 'xai'
9760 ];
9761 }
9762
9763
9764 }
9765 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
9766 try {
9767 // Ensure conversation_history is an array
9768 if (!is_array($conversation_history)) {
9769 $conversation_history = array();
9770 }
9771
9772 // Get bot ID from session or request
9773 $bot_id = $this->get_current_bot_id($session_id);
9774
9775 // Get system prompt instructions using centralized function
9776 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9777
9778 // Create a new array for the formatted conversation
9779 $formatted_conversation = array();
9780
9781 // Add system message first
9782 $formatted_conversation[] = array(
9783 'role' => 'system',
9784 'content' => $system_prompt_instructions . " " . $relevant_content
9785 );
9786
9787 // Add the rest of the conversation history
9788 foreach ($conversation_history as $message) {
9789 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9790 $role = $message['role'];
9791
9792 // Convert roles to supported format
9793 if ($role === 'bot' || $role === 'agent') {
9794 $role = 'assistant';
9795 }
9796 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9797 $role = 'user';
9798 }
9799
9800 $formatted_conversation[] = array(
9801 'role' => $role,
9802 'content' => $message['content']
9803 );
9804 }
9805 }
9806
9807 $body = json_encode([
9808 'model' => $selected_model,
9809 'messages' => $formatted_conversation,
9810 'temperature' => 0.8,
9811 'stream' => false
9812 ]);
9813
9814 $args = [
9815 'body' => $body,
9816 'headers' => [
9817 'Content-Type' => 'application/json',
9818 'Authorization' => 'Bearer ' . $deepseek_api_key,
9819 ],
9820 'timeout' => 60,
9821 'redirection' => 5,
9822 'blocking' => true,
9823 'httpversion' => '1.0',
9824 'sslverify' => true,
9825 ];
9826
9827 $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
9828
9829 if (is_wp_error($response)) {
9830 $error_message = $response->get_error_message();
9831 //error_log('DeepSeek API Error: ' . $error_message);
9832 return [
9833 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
9834 'error_code' => 'deepseek_connection_error',
9835 'provider' => 'deepseek'
9836 ];
9837 }
9838
9839 $status_code = wp_remote_retrieve_response_code($response);
9840 if ($status_code !== 200) {
9841 $response_body = wp_remote_retrieve_body($response);
9842 $decoded_response = json_decode($response_body, true);
9843
9844 $error_message = isset($decoded_response['error']['message'])
9845 ? $decoded_response['error']['message']
9846 : 'HTTP Error ' . $status_code;
9847
9848 $error_type = isset($decoded_response['error']['type'])
9849 ? $decoded_response['error']['type']
9850 : 'unknown';
9851
9852 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
9853
9854 // Handle specific error types
9855 switch ($status_code) {
9856 case 401:
9857 return [
9858 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
9859 'error_code' => 'deepseek_auth_error',
9860 'provider' => 'deepseek'
9861 ];
9862
9863 case 400:
9864 if (strpos($error_message, 'API key') !== false) {
9865 return [
9866 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
9867 'error_code' => 'deepseek_invalid_api_key',
9868 'provider' => 'deepseek'
9869 ];
9870 }
9871 break;
9872
9873 case 429:
9874 if (strpos($error_message, 'quota') !== false) {
9875 return [
9876 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
9877 'error_code' => 'deepseek_quota_exceeded',
9878 'provider' => 'deepseek'
9879 ];
9880 } else {
9881 return [
9882 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
9883 'error_code' => 'deepseek_rate_limit',
9884 'provider' => 'deepseek'
9885 ];
9886 }
9887
9888 case 500:
9889 case 502:
9890 case 503:
9891 case 504:
9892 return [
9893 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
9894 'error_code' => 'deepseek_service_unavailable',
9895 'provider' => 'deepseek'
9896 ];
9897 }
9898
9899 // Generic error fallback
9900 return [
9901 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
9902 'error_code' => 'deepseek_api_error',
9903 'provider' => 'deepseek',
9904 'status_code' => $status_code
9905 ];
9906 }
9907
9908 $response_body = wp_remote_retrieve_body($response);
9909 $decoded_response = json_decode($response_body, true);
9910
9911 if (isset($decoded_response['choices'][0]['message']['content'])) {
9912 return trim($decoded_response['choices'][0]['message']['content']);
9913 } else {
9914 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
9915 return [
9916 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
9917 'error_code' => 'deepseek_response_format_error',
9918 'provider' => 'deepseek'
9919 ];
9920 }
9921 } catch (Exception $e) {
9922 //error_log('DeepSeek Exception: ' . $e->getMessage());
9923 return [
9924 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
9925 'error_code' => 'deepseek_exception',
9926 'provider' => 'deepseek'
9927 ];
9928 }
9929 }
9930 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
9931 // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
9932 // Auto-rescue existing installs whose saved model is the dead ID.
9933 if ($selected_model === 'gemini-3-pro-preview') {
9934 $selected_model = 'gemini-3.1-pro-preview';
9935 }
9936 // Get bot ID from session or request
9937 $bot_id = $this->get_current_bot_id($session_id);
9938
9939 // Get system prompt instructions using centralized function
9940 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9941
9942 // Add system prompt to relevant content
9943 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
9944
9945 // Format messages for Gemini API
9946 $formatted_messages = [];
9947
9948 // Add system message as the first user message with role prefix
9949 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
9950 $formatted_messages[] = [
9951 'role' => 'user',
9952 'parts' => [
9953 ['text' => "[System Instructions] " . $content_with_instructions]
9954 ]
9955 ];
9956
9957 // Add model response to acknowledge system instructions
9958 $formatted_messages[] = [
9959 'role' => 'model',
9960 'parts' => [
9961 ['text' => "I understand and will follow these instructions."]
9962 ]
9963 ];
9964
9965 // Process the rest of the conversation history
9966 $current_role = null;
9967 $current_parts = [];
9968
9969 foreach ($conversation_history as $message) {
9970 // Skip the first system message as we already handled it
9971 if ($message['role'] === 'system') {
9972 continue;
9973 }
9974
9975 // Map roles to Gemini format
9976 $gemini_role = '';
9977 if ($message['role'] === 'user') {
9978 $gemini_role = 'user';
9979 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
9980 $gemini_role = 'model';
9981 } else {
9982 // Skip unsupported roles
9983 continue;
9984 }
9985
9986 // If we have a new role, add the previous message
9987 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
9988 $formatted_messages[] = [
9989 'role' => $current_role,
9990 'parts' => $current_parts
9991 ];
9992 $current_parts = [];
9993 }
9994
9995 // Set current role and add text to parts
9996 $current_role = $gemini_role;
9997 $current_parts[] = ['text' => $message['content']];
9998 }
9999
10000 // Add the last message if there's content
10001 if ($current_role !== null && !empty($current_parts)) {
10002 $formatted_messages[] = [
10003 'role' => $current_role,
10004 'parts' => $current_parts
10005 ];
10006 }
10007
10008 // Build the request body
10009 $body = json_encode([
10010 'contents' => $formatted_messages,
10011 'generationConfig' => [
10012 'temperature' => 0.7,
10013 'topP' => 0.95,
10014 'topK' => 40,
10015 'maxOutputTokens' => 8192,
10016 ],
10017 'safetySettings' => [
10018 [
10019 'category' => 'HARM_CATEGORY_HARASSMENT',
10020 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10021 ],
10022 [
10023 'category' => 'HARM_CATEGORY_HATE_SPEECH',
10024 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10025 ],
10026 [
10027 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
10028 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10029 ],
10030 [
10031 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
10032 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
10033 ]
10034 ]
10035 ]);
10036
10037 // Prepare the API endpoint
10038 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
10039 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
10040 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
10041
10042 // Set up the API request
10043 $args = [
10044 'body' => $body,
10045 'headers' => [
10046 'Content-Type' => 'application/json',
10047 ],
10048 'timeout' => 60,
10049 'redirection' => 5,
10050 'blocking' => true,
10051 'httpversion' => '1.0',
10052 'sslverify' => true,
10053 ];
10054
10055 // Make the API request
10056 $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
10057
10058 // Process the response
10059 if (is_wp_error($response)) {
10060 return "Sorry, there was an error processing your request: " . $response->get_error_message();
10061 }
10062
10063 $response_body = json_decode(wp_remote_retrieve_body($response), true);
10064
10065 // Handle potential errors in the response
10066 if (isset($response_body['error'])) {
10067 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
10068 return "Sorry, there was an error with the Gemini API: " .
10069 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
10070 }
10071
10072 // Extract the response text
10073 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
10074 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
10075 } else {
10076 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
10077 return "Sorry, I couldn't process that request. The response format was unexpected.";
10078 }
10079 }
10080
10081
10082 public function test_streaming_request() {
10083 $options = get_option('mxchat_options', []);
10084 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
10085
10086 // Detect provider from model prefix
10087 $provider = strtolower(explode('-', $model)[0]);
10088
10089 $sample_prompt = 'Hello! Can you stream this response back to me?';
10090 $messages = [['role' => 'user', 'content' => $sample_prompt]];
10091 $headers = [];
10092 $body = [];
10093 $url = '';
10094 $api_key = '';
10095
10096 switch ($provider) {
10097 case 'gpt':
10098 case 'o1':
10099 $api_key = $options['api_key'] ?? '';
10100 if (empty($api_key)) return '❌ Missing API key for OpenAI';
10101 $url = 'https://api.openai.com/v1/chat/completions';
10102 $headers = [
10103 'Content-Type: application/json',
10104 'Authorization: Bearer ' . $api_key
10105 ];
10106 $body = [
10107 'model' => $model,
10108 'messages' => $messages,
10109 'stream' => true
10110 ];
10111 break;
10112
10113 case 'claude':
10114 $api_key = $options['claude_api_key'] ?? '';
10115 if (empty($api_key)) return '❌ Missing API key for Claude';
10116 $url = 'https://api.anthropic.com/v1/messages';
10117 $headers = [
10118 'Content-Type: application/json',
10119 'x-api-key: ' . $api_key,
10120 'anthropic-version: 2023-06-01'
10121 ];
10122 $body = [
10123 'model' => $model,
10124 'messages' => $messages,
10125 'max_tokens' => 100,
10126 'stream' => true
10127 ];
10128 break;
10129
10130 case 'grok':
10131 $api_key = $options['xai_api_key'] ?? '';
10132 if (empty($api_key)) return '❌ Missing API key for X.AI';
10133 $url = 'https://api.x.ai/v1/chat/completions';
10134 $headers = [
10135 'Content-Type: application/json',
10136 'Authorization: Bearer ' . $api_key
10137 ];
10138 $body = [
10139 'model' => $model,
10140 'messages' => $messages,
10141 'stream' => true
10142 ];
10143 break;
10144
10145 case 'deepseek':
10146 if (empty($deepseek_api_key)) {
10147 $error_response = [
10148 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
10149 'error_code' => 'missing_deepseek_api_key'
10150 ];
10151 if ($testing_data !== null) {
10152 $error_response['testing_data'] = $testing_data;
10153 }
10154 return $error_response;
10155 }
10156 if ($streaming) {
10157 return $this->mxchat_generate_response_deepseek_stream(
10158 $selected_model,
10159 $deepseek_api_key,
10160 $conversation_history,
10161 $relevant_content,
10162 $session_id,
10163 $testing_data // Pass testing data
10164 );
10165 } else {
10166 $response = $this->mxchat_generate_response_deepseek(
10167 $selected_model,
10168 $deepseek_api_key,
10169 $conversation_history,
10170 $relevant_content
10171 );
10172 }
10173 break;
10174
10175 case 'gemini':
10176 $api_key = $options['gemini_api_key'] ?? '';
10177 if (empty($api_key)) return '❌ Missing API key for Gemini';
10178 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
10179 $headers = ['Content-Type: application/json'];
10180 $body = [
10181 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
10182 'generationConfig' => ['temperature' => 0.7]
10183 ];
10184 break;
10185
10186 default:
10187 return '❌ Unsupported provider: ' . $provider;
10188 }
10189
10190 // Do the actual streaming test
10191 $ch = curl_init($url);
10192 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
10193 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
10194 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
10195 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
10196 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10197
10198 $response = curl_exec($ch);
10199 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
10200 $error = curl_error($ch);
10201 curl_close($ch);
10202
10203 if ($error) return "❌ cURL error: $error";
10204 if ($http_code !== 200) {
10205 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
10206 return "❌ HTTP $http_code: $error_message";
10207 }
10208
10209 return true;
10210 }
10211
10212 public function mxchat_dismiss_pre_chat_message() {
10213 // Get and sanitize the user identifier
10214 $user_id = $this->mxchat_get_user_identifier();
10215 $user_id = sanitize_key($user_id);
10216
10217 // Set a transient to track that the user has dismissed the pre-chat message
10218 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
10219 set_transient($transient_key, true, DAY_IN_SECONDS);
10220
10221 wp_send_json_success();
10222 }
10223
10224 public function mxchat_check_pre_chat_message_status() {
10225 // Get and sanitize the user identifier
10226 $user_id = $this->mxchat_get_user_identifier();
10227 $user_id = sanitize_key($user_id);
10228
10229 // Check if the transient exists (i.e., if the message was dismissed)
10230 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
10231 $dismissed = get_transient($transient_key);
10232
10233 // Log the result to see if it's being set correctly
10234 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
10235
10236 if ($dismissed) {
10237 wp_send_json_success(['dismissed' => true]);
10238 } else {
10239 wp_send_json_success(['dismissed' => false]);
10240 }
10241
10242 wp_die();
10243 }
10244
10245 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
10246 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
10247 return 0;
10248 }
10249
10250 $dotProduct = array_sum(array_map(function ($a, $b) {
10251 return $a * $b;
10252 }, $vectorA, $vectorB));
10253 $normA = sqrt(array_sum(array_map(function ($a) {
10254 return $a * $a;
10255 }, $vectorA)));
10256 $normB = sqrt(array_sum(array_map(function ($b) {
10257 return $b * $b;
10258 }, $vectorB)));
10259
10260 if ($normA == 0 || $normB == 0) {
10261 return 0;
10262 }
10263
10264 return $dotProduct / ($normA * $normB);
10265 }
10266
10267
10268 public function mxchat_enqueue_scripts_styles() {
10269 // Fetch options from the database first to check loading strategy
10270 $this->options = get_option('mxchat_options');
10271 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
10272
10273 // Always enqueue CSS immediately
10274 wp_enqueue_style(
10275 'mxchat-chat-css',
10276 plugin_dir_url(__FILE__) . '../css/chat-style.css',
10277 array(),
10278 MXCHAT_VERSION
10279 );
10280
10281 // Handle script loading based on strategy
10282 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
10283 // Enqueue the script normally
10284 wp_enqueue_script(
10285 'mxchat-chat-js',
10286 plugin_dir_url(__FILE__) . '../js/chat-script.js',
10287 array('jquery'),
10288 MXCHAT_VERSION,
10289 true
10290 );
10291
10292 // Add defer attribute if strategy is 'defer'
10293 if ($loading_strategy === 'defer') {
10294 wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
10295 }
10296 } else {
10297 // For delay or interaction-based loading, we'll use a custom loader
10298 // Don't enqueue the main script - we'll load it dynamically
10299 add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
10300 }
10301
10302 $prompts_options = get_option('mxchat_prompts_options', array());
10303
10304 // Check if AI theme is active - if so, skip inline colors in JavaScript
10305 $theme_options = get_option('mxchat_theme_options', array());
10306 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
10307 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
10308 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
10309
10310 // Prepare settings for JavaScript
10311 $style_settings = array(
10312 'ajax_url' => admin_url('admin-ajax.php'),
10313 // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
10314 // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
10315 // as a one-shot fallback for the first interaction on a fresh page load
10316 // (so the very first chat-send doesn't need to wait for a REST round-trip),
10317 // but the widget refetches before each subsequent send.
10318 'nonce' => wp_create_nonce('mxchat_chat_send'),
10319 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
10320 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
10321 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
10322 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
10323 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
10324 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
10325 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
10326 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
10327 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
10328 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
10329 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
10330 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
10331 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
10332 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
10333 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
10334 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
10335 'icon_color' => $this->options['icon_color'] ?? '#fff',
10336 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
10337 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
10338 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
10339 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
10340 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
10341 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
10342 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
10343 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
10344 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
10345 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
10346 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
10347 'initial_email_state' => null, // Also fixed this undefined variable
10348 'skip_email_check' => true,
10349 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
10350 'skip_inline_colors' => $skip_inline_colors,
10351 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
10352 'print_button_enabled' => $this->options['print_button_enabled'] ?? 'on',
10353 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
10354 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
10355 'satisfaction_rating_enabled' => apply_filters(
10356 'mxchat_satisfaction_rating_enabled',
10357 ($this->options['satisfaction_rating_enabled'] ?? 'off') === 'on'
10358 ),
10359 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($this->options['satisfaction_rating_idle_seconds'] ?? 60))),
10360 'satisfaction_rating_copy' => array(
10361 'question' => !empty($this->options['satisfaction_rating_question']) ? esc_html($this->options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
10362 'helpful' => esc_html__('Helpful', 'mxchat'),
10363 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
10364 'dismiss' => esc_html__('Dismiss', 'mxchat'),
10365 'thanks' => !empty($this->options['satisfaction_rating_thanks']) ? esc_html($this->options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
10366 'placeholder' => !empty($this->options['satisfaction_rating_placeholder']) ? esc_html($this->options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
10367 'send' => esc_html__('Send', 'mxchat'),
10368 'skip' => esc_html__('Skip', 'mxchat'),
10369 'saved' => !empty($this->options['satisfaction_rating_saved']) ? esc_html($this->options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
10370 ),
10371 );
10372
10373 // For normal/defer loading, use wp_localize_script
10374 // For delayed loading, we store settings in a transient to be output inline
10375 if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
10376 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
10377 } else {
10378 // Store settings for the delayed loader to use
10379 set_transient('mxchat_delayed_settings_' . get_current_user_id(), $style_settings, 60);
10380 }
10381 }
10382
10383 /**
10384 * Output the delayed script loader for performance optimization
10385 */
10386 public function mxchat_output_delayed_script_loader() {
10387 $this->options = get_option('mxchat_options');
10388 $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
10389 $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
10390
10391 // Get the stored settings
10392 $prompts_options = get_option('mxchat_prompts_options', array());
10393 $theme_options = get_option('mxchat_theme_options', array());
10394 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
10395 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
10396 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
10397
10398 $style_settings = array(
10399 'ajax_url' => admin_url('admin-ajax.php'),
10400 // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
10401 // before each send. This inline value is a one-shot fallback for the first interaction.
10402 'nonce' => wp_create_nonce('mxchat_chat_send'),
10403 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
10404 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
10405 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
10406 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
10407 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
10408 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
10409 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
10410 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
10411 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
10412 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
10413 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
10414 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
10415 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
10416 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
10417 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
10418 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
10419 'icon_color' => $this->options['icon_color'] ?? '#fff',
10420 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
10421 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
10422 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
10423 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
10424 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
10425 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
10426 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
10427 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
10428 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
10429 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
10430 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
10431 'initial_email_state' => null,
10432 'skip_email_check' => true,
10433 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
10434 'skip_inline_colors' => $skip_inline_colors,
10435 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
10436 'print_button_enabled' => $this->options['print_button_enabled'] ?? 'on',
10437 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
10438 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
10439 'satisfaction_rating_enabled' => apply_filters(
10440 'mxchat_satisfaction_rating_enabled',
10441 ($this->options['satisfaction_rating_enabled'] ?? 'off') === 'on'
10442 ),
10443 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($this->options['satisfaction_rating_idle_seconds'] ?? 60))),
10444 'satisfaction_rating_copy' => array(
10445 'question' => !empty($this->options['satisfaction_rating_question']) ? esc_html($this->options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
10446 'helpful' => esc_html__('Helpful', 'mxchat'),
10447 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
10448 'dismiss' => esc_html__('Dismiss', 'mxchat'),
10449 'thanks' => !empty($this->options['satisfaction_rating_thanks']) ? esc_html($this->options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
10450 'placeholder' => !empty($this->options['satisfaction_rating_placeholder']) ? esc_html($this->options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
10451 'send' => esc_html__('Send', 'mxchat'),
10452 'skip' => esc_html__('Skip', 'mxchat'),
10453 'saved' => !empty($this->options['satisfaction_rating_saved']) ? esc_html($this->options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
10454 ),
10455 );
10456
10457 // Determine delay time based on strategy
10458 $delay_ms = 0;
10459 switch ($loading_strategy) {
10460 case 'delay_1s':
10461 $delay_ms = 1000;
10462 break;
10463 case 'delay_3s':
10464 $delay_ms = 3000;
10465 break;
10466 case 'delay_5s':
10467 $delay_ms = 5000;
10468 break;
10469 }
10470
10471 ?>
10472 <script type="text/javascript">
10473 (function() {
10474 var mxchatLoaded = false;
10475 var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
10476 window.mxchatChat = mxchatChat;
10477
10478 function loadMxChatScript() {
10479 if (mxchatLoaded) return;
10480 mxchatLoaded = true;
10481
10482 function appendChatScript() {
10483 var script = document.createElement('script');
10484 script.src = <?php echo wp_json_encode($script_url); ?>;
10485 script.type = 'text/javascript';
10486 document.body.appendChild(script);
10487 }
10488
10489 if (typeof jQuery !== 'undefined') {
10490 appendChatScript();
10491 } else {
10492 var jq = document.createElement('script');
10493 jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
10494 jq.onload = appendChatScript;
10495 document.body.appendChild(jq);
10496 }
10497 }
10498
10499 <?php if ($loading_strategy === 'on_interaction'): ?>
10500 // Load on user interaction
10501 var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
10502 events.forEach(function(evt) {
10503 window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
10504 });
10505 // Fallback: load after 8 seconds if no interaction
10506 setTimeout(loadMxChatScript, 8000);
10507 <?php else: ?>
10508 // Load after specified delay
10509 setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
10510 <?php endif; ?>
10511 })();
10512 </script>
10513 <?php
10514 }
10515
10516 /**
10517 * Setup the cron jobs for rate limits with guard against multiple calls
10518 */
10519 public function setup_rate_limit_cron_jobs() {
10520 // Add a guard to prevent multiple rapid calls
10521 $last_setup = get_transient('mxchat_cron_setup_guard');
10522 if ($last_setup && (time() - $last_setup) < 60) {
10523 // Don't run again if we ran less than 60 seconds ago
10524 return;
10525 }
10526
10527 // Set the guard
10528 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
10529
10530 try {
10531 // First, check if WordPress cron is disabled
10532 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
10533 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
10534 $this->setup_fallback_rate_limit_system();
10535 return;
10536 }
10537
10538 // Check if cron is already scheduled - if so, don't mess with it
10539 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
10540 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
10541 return;
10542 }
10543
10544 // Clear any orphaned hooks (but don't loop indefinitely)
10545 $hooks_to_clear = [
10546 'mxchat_reset_rate_limits',
10547 'mxchat_reset_hourly_rate_limits',
10548 'mxchat_reset_daily_rate_limits',
10549 'mxchat_reset_weekly_rate_limits',
10550 'mxchat_reset_monthly_rate_limits'
10551 ];
10552
10553 foreach ($hooks_to_clear as $hook) {
10554 // Only clear a maximum of 3 instances to prevent infinite loops
10555 $cleared = 0;
10556 while (wp_next_scheduled($hook) && $cleared < 3) {
10557 wp_clear_scheduled_hook($hook);
10558 $cleared++;
10559 }
10560 }
10561
10562 // Small delay after clearing
10563 usleep(100000); // 0.1 seconds
10564
10565 // Try to schedule the event
10566 $initial_time = time() + 300; // Start in 5 minutes
10567 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
10568
10569 if ($result === false) {
10570 //error_log('MxChat: Failed to schedule cron, using fallback system');
10571 $this->setup_fallback_rate_limit_system();
10572 } else {
10573 //error_log('MxChat: Successfully scheduled rate limit reset cron');
10574 }
10575
10576 } catch (Exception $e) {
10577 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
10578 $this->setup_fallback_rate_limit_system();
10579 }
10580 }
10581
10582 /**
10583 * Try alternative cron scheduling methods
10584 */
10585 private function try_alternative_cron_scheduling($initial_time) {
10586 try {
10587 // Method 1: Try with current time instead of future time
10588 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
10589 if ($result1 !== false) {
10590 //error_log('MxChat: Alternative method 1 (current time) succeeded');
10591 return true;
10592 }
10593
10594 // Method 2: Try with a different interval
10595 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
10596 if ($result2 !== false) {
10597 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
10598 return true;
10599 }
10600
10601 // Method 3: Try wp_schedule_single_event first, then recurring
10602 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
10603 if ($result3 !== false) {
10604 //error_log('MxChat: Alternative method 3 (single event) succeeded');
10605 // Schedule the next one manually in the handler
10606 return true;
10607 }
10608
10609 return false;
10610
10611 } catch (Exception $e) {
10612 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
10613 return false;
10614 }
10615 }
10616
10617 /**
10618 * Enhanced fallback rate limit system
10619 */
10620 private function setup_fallback_rate_limit_system() {
10621 // Set a flag to use database-based rate limit cleanup
10622 update_option('mxchat_use_fallback_rate_limits', true);
10623
10624 // Schedule a one-time check to happen on the next plugin load
10625 update_option('mxchat_next_rate_limit_check', time() + 3600);
10626
10627 // Also set up a more frequent fallback check (every 4 hours)
10628 update_option('mxchat_fallback_check_interval', 4 * 3600);
10629
10630 //error_log('MxChat: Fallback rate limit system activated');
10631 }
10632
10633 /**
10634 * Enhanced fallback check method
10635 */
10636 public function check_fallback_rate_limits() {
10637 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
10638
10639 if (!$use_fallback) {
10640 return; // Regular cron is working
10641 }
10642
10643 $next_check = get_option('mxchat_next_rate_limit_check', 0);
10644 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
10645
10646 if (time() >= $next_check) {
10647 //error_log('MxChat: Running fallback rate limit cleanup');
10648 $this->mxchat_reset_rate_limits();
10649
10650 // Schedule next check
10651 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
10652 }
10653 }
10654 /**
10655 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
10656 */
10657 public function check_rate_limit() {
10658 // Check if we need to run fallback cleanup
10659 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
10660 $next_check = get_option('mxchat_next_rate_limit_check', 0);
10661
10662 if ($use_fallback && time() >= $next_check) {
10663 $this->mxchat_reset_rate_limits();
10664 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
10665 }
10666
10667 // Get bot ID from current request context
10668 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
10669
10670 // Get bot-specific options (includes rate limits if overridden)
10671 $bot_options = $this->get_bot_options($bot_id);
10672 $current_options = !empty($bot_options) ? $bot_options : $this->options;
10673
10674 // Use bot-specific rate limits if available, otherwise fall back to default
10675 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
10676
10677 // -------------------------------------------------------------------
10678 // Whole-chatbot global cap (independent of role). Evaluated FIRST so
10679 // it acts as a hard ceiling across all users + all roles. Default is
10680 // 'unlimited' so existing installs are unchanged. Counter key drops
10681 // both <role> and <user_id> segments — single pool per bot.
10682 // -------------------------------------------------------------------
10683 $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
10684 ? $current_options['rate_limits_global']
10685 : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
10686 $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
10687 $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
10688 if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
10689 $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
10690 $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
10691 $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
10692 $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
10693 if ((int) $global_data['count'] === 0) {
10694 $global_data['timestamp'] = time();
10695 update_option($global_option, $global_data);
10696 }
10697 $now = time();
10698 $ts = (int) $global_data['timestamp'];
10699 $reset = false;
10700 switch ($global_timeframe) {
10701 case 'hourly': $reset = ($now - $ts) >= 3600; break;
10702 case 'daily': $reset = ($now - $ts) >= 86400; break;
10703 case 'weekly': $reset = ($now - $ts) >= 604800; break;
10704 case 'monthly': $reset = ($now - $ts) >= 2592000; break;
10705 }
10706 if ($reset) {
10707 $global_data = ['count' => 0, 'timestamp' => $now];
10708 update_option($global_option, $global_data);
10709 }
10710 if ((int) $global_data['count'] >= (int) $global_limit_raw) {
10711 $global_msg = !empty($global_cfg['message'])
10712 ? $global_cfg['message']
10713 : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
10714 return [
10715 'error' => true,
10716 'message' => $this->process_rate_limit_message_html($global_msg),
10717 ];
10718 }
10719 // Reserve the slot for this request. Per-role check below also increments
10720 // its own counter — that is intentional, both ceilings apply independently.
10721 $global_data['count']++;
10722 update_option($global_option, $global_data);
10723 }
10724
10725 // Determine user role or if logged out
10726 if (is_user_logged_in()) {
10727 $user = wp_get_current_user();
10728 $user_id = $user->ID;
10729
10730 // Get the user's primary role using reset() to safely get the first element
10731 $user_roles = $user->roles;
10732
10733 // Safely get the first role regardless of array key structure
10734 if (!empty($user_roles) && is_array($user_roles)) {
10735 $role = reset($user_roles); // This safely gets the first element regardless of key
10736 } else {
10737 $role = 'subscriber'; // Default to subscriber if no role found
10738 }
10739 } else {
10740 $role = 'logged_out';
10741 // Use IP address for non-logged-in users
10742 $user_id = $this->get_client_ip();
10743 }
10744
10745 // Check if rate limits are configured for this role
10746 if (!isset($rate_limits_source[$role])) {
10747 return true; // No limit set for this role
10748 }
10749
10750 $limit = $rate_limits_source[$role]['limit'];
10751
10752 // If unlimited, return true immediately
10753 if ($limit === 'unlimited') {
10754 return true;
10755 }
10756
10757 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
10758 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
10759 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
10760 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
10761
10762 // Include bot_id in option name so each bot has separate rate limits
10763 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
10764
10765 // Get the counter data
10766 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
10767
10768 // If first request or counter reset needed, set the initial timestamp
10769 if ($limit_data['count'] === 0) {
10770 $limit_data['timestamp'] = time();
10771 update_option($option_name, $limit_data);
10772 }
10773
10774 // Get the timeframe
10775 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
10776 $rate_limits_source[$role]['timeframe'] : 'daily';
10777
10778 // Check if the counter needs to be reset based on timeframe
10779 $current_time = time();
10780 $timestamp = $limit_data['timestamp'];
10781 $should_reset = false;
10782
10783 switch ($timeframe) {
10784 case 'hourly':
10785 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
10786 break;
10787 case 'daily':
10788 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
10789 break;
10790 case 'weekly':
10791 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
10792 break;
10793 case 'monthly':
10794 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
10795 break;
10796 }
10797
10798 // Reset the counter if the timeframe has passed
10799 if ($should_reset) {
10800 $limit_data = ['count' => 0, 'timestamp' => $current_time];
10801 update_option($option_name, $limit_data);
10802 }
10803
10804 // Check if user has exceeded their limit
10805 if ($limit_data['count'] >= intval($limit)) {
10806 // Get the custom message for this role
10807 $message = !empty($rate_limits_source[$role]['message'])
10808 ? $rate_limits_source[$role]['message']
10809 : __('Rate limit exceeded. Please try again later.', 'mxchat');
10810
10811 // Add timeframe information to the message if placeholders exist
10812 $timeframe_label = '';
10813 switch ($timeframe) {
10814 case 'hourly':
10815 $timeframe_label = __('hour', 'mxchat');
10816 break;
10817 case 'daily':
10818 $timeframe_label = __('day', 'mxchat');
10819 break;
10820 case 'weekly':
10821 $timeframe_label = __('week', 'mxchat');
10822 break;
10823 case 'monthly':
10824 $timeframe_label = __('month', 'mxchat');
10825 break;
10826 }
10827
10828 // Replace placeholders in the message
10829 $message = str_replace(
10830 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
10831 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
10832 $message
10833 );
10834
10835 // Process HTML links in the message
10836 $message = $this->process_rate_limit_message_html($message);
10837
10838 // Return error with the processed message
10839 return [
10840 'error' => true,
10841 'message' => $message
10842 ];
10843 }
10844
10845 // Increment the counter
10846 $limit_data['count']++;
10847 update_option($option_name, $limit_data);
10848
10849 return true;
10850 }
10851
10852 /**
10853 * Enhanced rate limit reset with better error handling
10854 */
10855 public function mxchat_reset_rate_limits() {
10856 try {
10857 global $wpdb;
10858 $all_options = get_option('mxchat_options', []);
10859 $current_time = time();
10860
10861 // Get rate limit options with a safer query and limit
10862 $option_names = $wpdb->get_col(
10863 $wpdb->prepare(
10864 "SELECT option_name FROM {$wpdb->options}
10865 WHERE option_name LIKE %s
10866 LIMIT 1000",
10867 'mxchat_chat_limit_%'
10868 )
10869 );
10870
10871 if (empty($option_names)) {
10872 return;
10873 }
10874
10875 $processed_count = 0;
10876 $max_processing_time = 30; // Maximum 30 seconds
10877 $start_time = time();
10878
10879 foreach ($option_names as $option_name) {
10880 // Check processing time limit
10881 if ((time() - $start_time) > $max_processing_time) {
10882 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
10883 break;
10884 }
10885
10886 // Parse the option name more safely
10887 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
10888 continue;
10889 }
10890
10891 $role_and_user = $matches[1] . '_' . $matches[2];
10892 $parts = explode('_', $role_and_user);
10893
10894 if (count($parts) < 2) {
10895 continue;
10896 }
10897
10898 // Extract role (everything except the last part which is user ID)
10899 $user_id_part = array_pop($parts);
10900 $role = implode('_', $parts);
10901
10902 // Skip if role doesn't exist in our settings
10903 if (!isset($all_options['rate_limits'][$role])) {
10904 // Clean up orphaned entries
10905 delete_option($option_name);
10906 continue;
10907 }
10908
10909 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
10910 $limit_data = get_option($option_name);
10911
10912 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
10913 // Clean up invalid entries
10914 delete_option($option_name);
10915 continue;
10916 }
10917
10918 $timestamp = $limit_data['timestamp'];
10919 $should_reset = false;
10920
10921 // Determine if we should reset based on the timeframe
10922 switch ($timeframe) {
10923 case 'hourly':
10924 $should_reset = ($current_time - $timestamp) >= 3600;
10925 break;
10926 case 'daily':
10927 $should_reset = ($current_time - $timestamp) >= 86400;
10928 break;
10929 case 'weekly':
10930 $should_reset = ($current_time - $timestamp) >= 604800;
10931 break;
10932 case 'monthly':
10933 $should_reset = ($current_time - $timestamp) >= 2592000;
10934 break;
10935 }
10936
10937 // Reset the counter if the timeframe has passed
10938 if ($should_reset) {
10939 delete_option($option_name);
10940 wp_cache_delete($option_name, 'options');
10941 $processed_count++;
10942 }
10943 }
10944
10945 // Clean up any orphaned cache entries
10946 wp_cache_delete('mxchat_all_chat_limits', 'options');
10947
10948 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
10949
10950 } catch (Exception $e) {
10951 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
10952 }
10953 }
10954
10955
10956 /**
10957 * Process HTML links in rate limit messages
10958 *
10959 * @param string $message The rate limit message
10960 * @return string The processed message with safe HTML links
10961 */
10962 private function process_rate_limit_message_html($message) {
10963 // Return original message if empty
10964 if (empty($message)) {
10965 return $message;
10966 }
10967
10968 // First, convert markdown links to HTML
10969 $message = $this->convert_markdown_links($message);
10970
10971 // Then, auto-convert any remaining plain URLs to links
10972 $message = $this->auto_link_urls($message);
10973
10974 // Allow basic HTML tags for links and formatting
10975 $allowed_tags = [
10976 'a' => [
10977 'href' => true,
10978 'target' => true,
10979 'rel' => true,
10980 'title' => true,
10981 'class' => true
10982 ],
10983 'strong' => [],
10984 'em' => [],
10985 'br' => [],
10986 'b' => [],
10987 'i' => [],
10988 'span' => ['class' => true]
10989 ];
10990
10991 // Sanitize but allow the specified HTML tags
10992 $processed_message = wp_kses($message, $allowed_tags);
10993
10994 // If wp_kses stripped everything, return the original message as plain text
10995 if (empty($processed_message) && !empty($message)) {
10996 // Strip all HTML and return plain text as fallback
10997 return wp_strip_all_tags($message);
10998 }
10999
11000 return $processed_message;
11001 }
11002
11003 /**
11004 * Convert markdown links to HTML
11005 *
11006 * @param string $text The text to process
11007 * @return string The text with markdown links converted to HTML
11008 */
11009 private function convert_markdown_links($text) {
11010 // Return original text if empty
11011 if (empty($text)) {
11012 return $text;
11013 }
11014
11015 // Pattern to match markdown links: [text](url)
11016 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
11017
11018 $processed_text = preg_replace_callback($pattern, function($matches) {
11019 $link_text = $matches[1];
11020 $url = $matches[2];
11021
11022 // Clean up any trailing punctuation from the URL
11023 $url = rtrim($url, '.,;:!?');
11024
11025 // Sanitize the link text and URL
11026 $safe_text = esc_html($link_text);
11027 $safe_url = esc_url($url);
11028
11029 // Create the HTML link
11030 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
11031 }, $text);
11032
11033 // If preg_replace_callback failed, return original text
11034 if ($processed_text === null) {
11035 return $text;
11036 }
11037
11038 return $processed_text;
11039 }
11040
11041 /**
11042 * Auto-convert plain URLs to clickable links
11043 *
11044 * @param string $text The text to process
11045 * @return string The text with URLs converted to links
11046 */
11047 private function auto_link_urls($text) {
11048 // Return original text if empty
11049 if (empty($text)) {
11050 return $text;
11051 }
11052
11053 // Simple pattern that avoids complex lookbehinds
11054 // This will match URLs that are not already inside href attributes or markdown links
11055 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
11056
11057 $processed_text = preg_replace_callback($pattern, function($matches) {
11058 $url = $matches[0];
11059 // Clean up any trailing punctuation that might have been captured
11060 $url = rtrim($url, '.,;:!?');
11061
11062 // Add target="_blank" and rel="noopener noreferrer" for security
11063 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
11064 }, $text);
11065
11066 // If preg_replace_callback failed, return original text
11067 if ($processed_text === null) {
11068 return $text;
11069 }
11070
11071 return $processed_text;
11072 }
11073
11074
11075 // Helper function to get client IP address
11076 private function get_client_ip() {
11077 // Check for shared internet/ISP IP
11078 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
11079 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
11080 }
11081
11082 // Check for IPs passing through proxies
11083 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
11084 // Use the first value in the comma-separated list
11085 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
11086 return trim($forwarded_for[0]);
11087 }
11088
11089 if (!empty($_SERVER['REMOTE_ADDR'])) {
11090 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
11091 }
11092
11093 // Fallback
11094 return 'unknown';
11095 }
11096
11097 /**
11098 * AJAX handler to get system information for testing panel
11099 */
11100 /**
11101 * AJAX handler to get system information for testing panel
11102 */
11103 public function mxchat_get_system_info() {
11104 // Verify nonce for security
11105 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
11106 wp_send_json_error(['message' => 'Invalid nonce']);
11107 return;
11108 }
11109
11110 // Only allow admin users
11111 if (!current_user_can('administrator')) {
11112 wp_send_json_error(['message' => 'Unauthorized']);
11113 return;
11114 }
11115
11116 // Get system prompt from options
11117 $system_prompt = isset($this->options['system_prompt_instructions'])
11118 ? $this->options['system_prompt_instructions']
11119 : 'No system prompt configured';
11120
11121 // Get selected model
11122 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
11123
11124 // Check if OpenRouter is being used
11125 $is_openrouter = ($selected_model === 'openrouter');
11126 $openrouter_model = '';
11127
11128 if ($is_openrouter) {
11129 // Get the actual OpenRouter model that's selected
11130 $openrouter_model = isset($this->options['openrouter_selected_model'])
11131 ? $this->options['openrouter_selected_model']
11132 : 'No OpenRouter model selected';
11133
11134 // Update selected_model display to show both
11135 $selected_model = 'OpenRouter: ' . $openrouter_model;
11136 }
11137
11138 // Get API key status (just check if they exist, don't expose the keys)
11139 $api_status = [];
11140 $api_status['openai'] = !empty($this->options['api_key']);
11141 $api_status['claude'] = !empty($this->options['claude_api_key']);
11142 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
11143 $api_status['xai'] = !empty($this->options['xai_api_key']);
11144 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
11145 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
11146
11147 wp_send_json_success([
11148 'system_prompt' => $system_prompt,
11149 'selected_model' => $selected_model,
11150 'is_openrouter' => $is_openrouter,
11151 'openrouter_model' => $openrouter_model,
11152 'api_status' => $api_status
11153 ]);
11154 }
11155
11156 /**
11157 * AJAX handler to get similarity threshold
11158 */
11159 public function mxchat_get_similarity_threshold() {
11160 // Verify nonce for security
11161 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
11162 wp_send_json_error(['message' => 'Invalid nonce']);
11163 return;
11164 }
11165
11166 // Only allow admin users
11167 if (!current_user_can('administrator')) {
11168 wp_send_json_error(['message' => 'Unauthorized']);
11169 return;
11170 }
11171
11172 // Get similarity threshold from main options (default 35%)
11173 $similarity_threshold = isset($this->options['similarity_threshold'])
11174 ? ((int) $this->options['similarity_threshold']) / 100
11175 : 0.35;
11176
11177 wp_send_json_success([
11178 'threshold' => $similarity_threshold,
11179 'threshold_percentage' => ($similarity_threshold * 100) . '%'
11180 ]);
11181 }
11182
11183 /**
11184 * AJAX handler to get knowledge base status
11185 */
11186 public function mxchat_get_kb_status() {
11187 // Verify nonce for security
11188 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
11189 wp_send_json_error(['message' => 'Invalid nonce']);
11190 return;
11191 }
11192
11193 // Only allow admin users
11194 if (!current_user_can('administrator')) {
11195 wp_send_json_error(['message' => 'Unauthorized']);
11196 return;
11197 }
11198
11199 // Check OpenAI Vector Store first (takes priority)
11200 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
11201 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
11202
11203 if ($use_vectorstore) {
11204 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
11205 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
11206
11207 $kb_info = [
11208 'type' => 'OpenAI Vector Store',
11209 'status' => 'Active',
11210 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
11211 ];
11212
11213 wp_send_json_success($kb_info);
11214 return;
11215 }
11216
11217 // Check Pinecone vs WordPress
11218 $addon_options = get_option('mxchat_pinecone_addon_options', array());
11219 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
11220
11221 $kb_info = [
11222 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
11223 'status' => 'Active'
11224 ];
11225
11226 // Get document count
11227 if ($use_pinecone) {
11228 $kb_info['documents'] = 'Connected to Pinecone';
11229 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
11230 } else {
11231 // Count documents in WordPress database
11232 global $wpdb;
11233 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11234 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
11235 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
11236 }
11237
11238 wp_send_json_success($kb_info);
11239 }
11240
11241 /**
11242 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
11243 */
11244 public function mxchat_start_fresh_session() {
11245 // Verify nonce for security
11246 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
11247 wp_send_json_error(['message' => 'Invalid nonce']);
11248 return;
11249 }
11250
11251 // Only allow admin users
11252 if (!current_user_can('administrator')) {
11253 wp_send_json_error(['message' => 'Unauthorized']);
11254 return;
11255 }
11256
11257 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
11258 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
11259
11260 if (empty($old_session_id)) {
11261 wp_send_json_error(['message' => 'Old session ID required']);
11262 return;
11263 }
11264
11265 // If no new session ID provided, generate one
11266 if (empty($new_session_id)) {
11267 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
11268 }
11269
11270 // Clear ALL data associated with the old session
11271 $this->clear_complete_session_data($old_session_id);
11272
11273 // Initialize the new session
11274 $this->initialize_fresh_session($new_session_id);
11275
11276 wp_send_json_success([
11277 'message' => 'Fresh session started successfully',
11278 'new_session_id' => $new_session_id,
11279 'old_session_id' => $old_session_id
11280 ]);
11281 }
11282
11283 /**
11284 * Clear ALL data associated with a session (ENHANCED)
11285 */
11286 private function clear_complete_session_data($session_id) {
11287 // Clear chat history
11288 delete_option("mxchat_history_{$session_id}");
11289
11290 // Clear chat mode
11291 delete_option("mxchat_mode_{$session_id}");
11292
11293 // Clear any PDF/Word transients
11294 $this->clear_pdf_transients($session_id);
11295 if (method_exists($this, 'clear_word_transients')) {
11296 $this->clear_word_transients($session_id);
11297 }
11298
11299 // Clear agent-related data
11300 delete_option("mxchat_channel_{$session_id}");
11301 delete_option("mxchat_agent_name_{$session_id}");
11302 delete_option("mxchat_email_{$session_id}");
11303
11304 // Clear any recommendation flow state
11305 delete_option("mxchat_sr_flow_state_{$session_id}");
11306
11307 // Clear any cached embeddings or context
11308 delete_transient("mxchat_context_{$session_id}");
11309 delete_transient("mxchat_last_query_{$session_id}");
11310
11311 // Clear any testing data
11312 delete_transient("mxchat_testing_data_{$session_id}");
11313
11314 // Clear any rate limiting data for this session
11315 delete_transient("mxchat_rate_limit_{$session_id}");
11316
11317 // Clear any other session-specific transients
11318 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
11319 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
11320 delete_transient("mxchat_include_word_in_context_{$session_id}");
11321
11322 // Clear form addon state (pending forms and submitted forms)
11323 delete_option("mxchat_pending_form_{$session_id}");
11324 delete_option("mxchat_submitted_forms_{$session_id}");
11325
11326 //error_log("MxChat: Cleared all data for session: {$session_id}");
11327 }
11328
11329 /**
11330 * Initialize a fresh session with default data
11331 */
11332 private function initialize_fresh_session($session_id) {
11333 // Set default chat mode
11334 update_option("mxchat_mode_{$session_id}", 'ai');
11335
11336 //error_log("MxChat: Initialized fresh session: {$session_id}");
11337 }
11338
11339 /**
11340 * Helper method to clear Word document transients (if you have Word support)
11341 */
11342 private function clear_word_transients($session_id) {
11343 delete_transient('mxchat_word_url_' . $session_id);
11344 delete_transient('mxchat_word_filename_' . $session_id);
11345 delete_transient('mxchat_word_embeddings_' . $session_id);
11346 delete_transient('mxchat_include_word_in_context_' . $session_id);
11347 }
11348
11349 /**
11350 * Simplified testing data capture method (CLEANED UP)
11351 */
11352 private function capture_testing_data($user_embedding, $message, $session_id) {
11353 // Only capture for admin users
11354 if (!current_user_can('administrator')) {
11355 return null;
11356 }
11357
11358 $testing_data = [
11359 'query' => $message,
11360 'timestamp' => time(),
11361 'top_matches' => [],
11362 'action_matches' => [] // Add action matches
11363 ];
11364
11365 // Get similarity threshold
11366 $similarity_threshold = isset($this->options['similarity_threshold'])
11367 ? ((int) $this->options['similarity_threshold']) / 100
11368 : 0.35;
11369
11370 $testing_data['similarity_threshold'] = $similarity_threshold;
11371
11372 // Use the real similarity analysis if available
11373 if ($this->last_similarity_analysis !== null) {
11374 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
11375 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
11376 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
11377 } else {
11378 // Fallback: determine knowledge base type
11379 $addon_options = get_option('mxchat_pinecone_addon_options', array());
11380 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
11381
11382 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
11383 }
11384
11385 // Include action analysis if available
11386 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
11387 $testing_data['action_matches'] = $this->last_action_analysis;
11388
11389 // Clear it after capturing to avoid stale data
11390 $this->last_action_analysis = null;
11391 }
11392
11393 return $testing_data;
11394 }
11395
11396
11397 /**
11398 * Track URL clicks from chatbot responses
11399 */
11400 public function mxchat_track_url_click() {
11401 // Verify nonce for security
11402 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
11403 wp_send_json_error(['message' => 'Invalid nonce']);
11404 wp_die();
11405 }
11406
11407 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
11408 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
11409 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
11410
11411 if (empty($session_id) || empty($clicked_url)) {
11412 wp_send_json_error(['message' => 'Missing required data']);
11413 wp_die();
11414 }
11415
11416 global $wpdb;
11417 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
11418
11419 // Insert click tracking record
11420 $wpdb->insert(
11421 $table_name,
11422 [
11423 'session_id' => $session_id,
11424 'clicked_url' => $clicked_url,
11425 'message_context' => $message_context,
11426 'click_timestamp' => current_time('mysql', 1),
11427 'user_ip' => $_SERVER['REMOTE_ADDR'],
11428 'user_agent' => $_SERVER['HTTP_USER_AGENT']
11429 ]
11430 );
11431
11432 wp_send_json_success(['message' => 'Click tracked']);
11433 wp_die();
11434 }
11435
11436 /**
11437 * Get URL click analytics for a session
11438 */
11439 public function mxchat_get_url_clicks($session_id) {
11440 global $wpdb;
11441 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
11442
11443 $clicks = $wpdb->get_results($wpdb->prepare(
11444 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
11445 $session_id
11446 ));
11447
11448 return $clicks;
11449 }
11450 /**
11451 * Track the originating page where chat was started
11452 */
11453 public function mxchat_track_originating_page() {
11454 // Verify nonce
11455 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
11456 wp_send_json_error(['message' => 'Invalid nonce']);
11457 wp_die();
11458 }
11459
11460 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
11461 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
11462 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
11463
11464 if (empty($session_id)) {
11465 wp_send_json_error(['message' => 'Missing session ID']);
11466 wp_die();
11467 }
11468
11469 global $wpdb;
11470 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
11471
11472 // Check if we've already tracked for this session
11473 $existing = $wpdb->get_var($wpdb->prepare(
11474 "SELECT COUNT(*) FROM $table_name
11475 WHERE session_id = %s
11476 AND originating_page_url IS NOT NULL",
11477 $session_id
11478 ));
11479
11480 if ($existing > 0) {
11481 wp_send_json_success(['message' => 'Already tracked']);
11482 wp_die();
11483 }
11484
11485 // Update the first message in this session with originating page info
11486 $wpdb->query($wpdb->prepare(
11487 "UPDATE $table_name
11488 SET originating_page_url = %s,
11489 originating_page_title = %s
11490 WHERE session_id = %s
11491 ORDER BY timestamp ASC
11492 LIMIT 1",
11493 $page_url,
11494 $page_title,
11495 $session_id
11496 ));
11497
11498 wp_send_json_success(['message' => 'Originating page tracked']);
11499 wp_die();
11500 }
11501
11502 /**
11503 * Validate and clean URLs from AI response
11504 * Removes any URLs that aren't in the knowledge base
11505 *
11506 * @param string $response_text The AI-generated response
11507 * @param array $valid_urls Array of URLs from the knowledge base
11508 * @return string Cleaned response with invalid URLs removed/flagged
11509 */
11510 private function validate_and_clean_urls($response_text, $valid_urls) {
11511 // DEBUG: Log what we're working with
11512 //error_log("=== MxChat URL Validation Debug ===");
11513 //error_log("Valid URLs count: " . count($valid_urls));
11514 //error_log("Valid URLs: " . print_r($valid_urls, true));
11515 //error_log("Response text length: " . strlen($response_text));
11516 //error_log("Response text preview: " . substr($response_text, 0, 500));
11517
11518 // If no valid URLs provided or empty response, return as-is
11519 if (empty($valid_urls) || empty($response_text)) {
11520 //error_log("Validation skipped - empty valid_urls or response");
11521 return $response_text;
11522 }
11523
11524 // Extract all URLs from the AI response
11525 // This regex matches http:// and https:// URLs
11526 preg_match_all(
11527 '#\bhttps?://[^\s<>"\')\]]+#i',
11528 $response_text,
11529 $matches
11530 );
11531
11532 // If no URLs found in response, return as-is
11533 if (empty($matches[0])) {
11534 //error_log("No URLs found in response");
11535 return $response_text;
11536 }
11537
11538 $found_urls = $matches[0];
11539 $cleaned_response = $response_text;
11540 $removed_count = 0;
11541
11542 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
11543 $normalized_valid_urls = array_map(function($url) {
11544 // Remove trailing slash
11545 $url = rtrim($url, '/');
11546 // Remove URL fragments (#section)
11547 $url = preg_replace('/#.*$/', '', $url);
11548 // Remove trailing punctuation that might have been captured
11549 $url = rtrim($url, '.,;:!?');
11550 return $url;
11551 }, $valid_urls);
11552
11553 //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
11554
11555 foreach ($found_urls as $found_url) {
11556 // Clean up the found URL (remove trailing punctuation that might have been captured)
11557 $clean_found_url = rtrim($found_url, '.,;:!?)');
11558
11559 // DEBUG: Log each URL being checked
11560 //error_log("Checking found URL: " . $found_url);
11561
11562 // Normalize for comparison
11563 $normalized_found = rtrim($clean_found_url, '/');
11564 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
11565
11566 //error_log("Normalized found URL: " . $normalized_found);
11567
11568 // Check if this URL exists in our valid URLs list
11569 $is_valid = false;
11570
11571 //error_log("Starting validation checks for: " . $normalized_found);
11572
11573 // First, try exact match
11574 if (in_array($normalized_found, $normalized_valid_urls)) {
11575 $is_valid = true;
11576 //error_log("EXACT MATCH FOUND");
11577 } else {
11578 //error_log("No exact match, checking variations...");
11579 // If no exact match, check if it's a variation (with query params, etc.)
11580 foreach ($normalized_valid_urls as $valid_url) {
11581 //error_log(" Comparing against valid URL: " . $valid_url);
11582
11583 // Check if the found URL starts with a valid URL (handles query params)
11584 if (strpos($normalized_found, $valid_url) === 0) {
11585 // Check what comes after the valid URL
11586 $remainder = substr($normalized_found, strlen($valid_url));
11587
11588 // Only valid if:
11589 // 1. Exact match (remainder is empty)
11590 // 2. Query params (starts with ?)
11591 // 3. Fragment (starts with #)
11592 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
11593 $is_valid = true;
11594 //error_log(" MATCH: Found URL is valid variation of base URL");
11595 break;
11596 } else {
11597 //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
11598 }
11599 }
11600 // Also check the reverse (in case valid URL has query params)
11601 if (strpos($valid_url, $normalized_found) === 0) {
11602 $is_valid = true;
11603 //error_log(" MATCH: Valid URL starts with found URL");
11604 break;
11605 }
11606 }
11607
11608 if (!$is_valid) {
11609 //error_log("NO MATCH FOUND - URL should be removed");
11610 }
11611 }
11612
11613 // If URL is not valid, remove it from the response
11614 if (!$is_valid) {
11615 // Log the removal for debugging
11616 //error_log("MxChat: Removed hallucinated URL: " . $found_url);
11617 //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
11618
11619 $removed_count++;
11620
11621 // Check if URL is part of a markdown link: [text](url)
11622 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
11623 if (preg_match($markdown_pattern, $cleaned_response)) {
11624 //error_log("Found markdown link, removing but keeping text");
11625 // Remove the markdown link but keep the text
11626 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
11627 }
11628 // Check if URL is part of an HTML link: <a href="url">text</a>
11629 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
11630 //error_log("Found HTML link, removing but keeping text");
11631 // Remove the HTML link but keep the text
11632 $link_text = $link_match[1];
11633 $cleaned_response = preg_replace(
11634 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
11635 $link_text,
11636 $cleaned_response
11637 );
11638 }
11639 // Otherwise just remove the bare URL
11640 else {
11641 //error_log("Removing bare URL");
11642 $cleaned_response = str_replace($found_url, '', $cleaned_response);
11643 }
11644 }
11645 }
11646
11647 // Log summary if any URLs were removed
11648 if ($removed_count > 0) {
11649 //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
11650 } else {
11651 //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
11652 }
11653
11654 // Clean up any double spaces or awkward punctuation left behind
11655 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
11656 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
11657 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
11658
11659 //error_log("Final cleaned response: " . $cleaned_response);
11660
11661 return trim($cleaned_response);
11662 }
11663
11664 /**
11665 * AJAX handler to get current chat mode for a session
11666 */
11667 public function mxchat_get_current_chat_mode() {
11668 // Verify nonce for security
11669 if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
11670 wp_send_json_error(['message' => 'Invalid nonce']);
11671 wp_die();
11672 }
11673
11674 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
11675
11676 if (empty($session_id)) {
11677 wp_send_json_error(['message' => 'Session ID missing']);
11678 wp_die();
11679 }
11680
11681 // Get the current chat mode for this session
11682 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
11683
11684 wp_send_json_success([
11685 'chat_mode' => $chat_mode
11686 ]);
11687 wp_die();
11688 }
11689
11690
11691
11692 }
11693 ?>
11694