PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.6
MxChat – AI Chatbot & Content Generation for WordPress v3.0.6
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.0.6, at includes/class-mxchat-integrator.php

10,250 lines 414.9 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 $is_streaming = false; // ADDED: Track if current request is streaming
16 private $streaming_headers_sent = false; // Track if streaming headers have been sent
17
18 /**
19 * Setup streaming headers - call this right before actually streaming
20 * This delays header setup to allow actions/forms to return JSON responses
21 */
22 private function setup_streaming_headers() {
23 if ($this->streaming_headers_sent || headers_sent()) {
24 return false;
25 }
26
27 // Disable output buffering
28 while (ob_get_level()) {
29 ob_end_flush();
30 }
31
32 // Set headers for SSE
33 header('Content-Type: text/event-stream');
34 header('Cache-Control: no-cache');
35 header('Connection: keep-alive');
36 header('X-Accel-Buffering: no');
37
38 ob_implicit_flush(true);
39 flush();
40
41 $this->streaming_headers_sent = true;
42 return true;
43 }
44
45 /**
46 * Class constructor
47 */
48 public function __construct() {
49 $this->options = get_option('mxchat_options');
50 $this->prompts_options = get_option('mxchat_prompts_options', array());
51 $this->chat_count = get_option('mxchat_chat_count', 0);
52 $this->word_handler = new MXChat_Word_Handler($this->options);
53
54 // Add all action hooks
55 add_action('wp_enqueue_scripts', array($this, 'mxchat_enqueue_scripts_styles'));
56 add_action('wp_ajax_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
57 add_action('wp_ajax_nopriv_mxchat_handle_chat_request', array($this, 'mxchat_handle_chat_request'));
58 add_action('wp_ajax_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
59 add_action('wp_ajax_nopriv_mxchat_dismiss_pre_chat_message', array($this, 'mxchat_dismiss_pre_chat_message'));
60
61 // Add the AJAX actions for checking if the pre-chat message was dismissed
62 add_action('wp_ajax_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
63 add_action('wp_ajax_nopriv_mxchat_check_pre_chat_message_status', array($this, 'mxchat_check_pre_chat_message_status'));
64 add_action('wp_ajax_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
65 add_action('wp_ajax_nopriv_mxchat_fetch_conversation_history', [$this, 'mxchat_fetch_conversation_history']);
66 add_action('wp_ajax_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
67 add_action('wp_ajax_nopriv_mxchat_add_to_cart', [$this, 'mxchat_add_to_cart']);
68
69 // Add REST API routes registration
70 add_action('rest_api_init', array($this, 'register_routes'));
71 add_action('wp_ajax_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
72 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
73
74 // Rate limit action - notice we removed the old schedule setup
75 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
76
77 // File upload and handling actions
78 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
79 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
80 add_action('wp_ajax_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
81 add_action('wp_ajax_nopriv_mxchat_remove_pdf', [$this, 'handle_pdf_remove']);
82
83 // Word document handling actions
84 add_action('wp_ajax_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
85 add_action('wp_ajax_nopriv_mxchat_upload_word', array($this, 'mxchat_handle_word_upload'));
86 add_action('wp_ajax_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
87 add_action('wp_ajax_nopriv_mxchat_remove_word', array($this, 'mxchat_handle_word_remove'));
88 add_action('wp_ajax_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
89 add_action('wp_ajax_nopriv_mxchat_check_word_status', array($this, 'mxchat_check_word_status'));
90
91 // Email handling actions
92 add_action('wp_ajax_nopriv_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
93 add_action('wp_ajax_mxchat_handle_save_email_and_response', [$this, 'mxchat_handle_save_email_and_response']);
94 add_action('wp_ajax_nopriv_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
95 add_action('wp_ajax_mxchat_check_email_provided', [$this, 'mxchat_check_email_provided']);
96
97 add_action('wp_ajax_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
98 add_action('wp_ajax_nopriv_mxchat_stream_chat', array($this, 'mxchat_handle_chat_request'));
99
100 // Testing panel AJAX actions
101 add_action('wp_ajax_mxchat_get_system_info', array($this, 'mxchat_get_system_info'));
102 add_action('wp_ajax_mxchat_get_similarity_threshold', array($this, 'mxchat_get_similarity_threshold'));
103 add_action('wp_ajax_mxchat_get_kb_status', array($this, 'mxchat_get_kb_status'));
104 add_action('wp_ajax_mxchat_start_fresh_session', array($this, 'mxchat_start_fresh_session'));
105 // Add to your existing constructor, in the section with other AJAX actions:
106 add_action('wp_ajax_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
107 add_action('wp_ajax_nopriv_mxchat_track_url_click', array($this, 'mxchat_track_url_click'));
108 add_action('wp_ajax_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
109 add_action('wp_ajax_nopriv_mxchat_track_originating_page', array($this, 'mxchat_track_originating_page'));
110 // Add chat mode checking actions
111 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
112 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
113
114 // Auto-email transcript action
115 add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
116
117 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
118
119
120 }
121
122 // In your core plugin's check_actions_for_addons method:
123 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
124 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
125
126 $result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
127
128 //error_log('MxChat Core: Intent check result = ' . ($result === false ? 'false' : 'true'));
129
130 return $result;
131 }
132
133 private function mxchat_increment_chat_count() {
134 $chat_count = get_option('mxchat_chat_count', 0);
135 $chat_count++;
136 update_option('mxchat_chat_count', $chat_count);
137 }
138
139 function mxchat_fetch_conversation_history() {
140 if (empty($_POST['session_id'])) {
141 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
142 wp_die();
143 }
144
145 $session_id = sanitize_text_field($_POST['session_id']);
146
147 // SECURITY FIX: Verify session ownership before retrieving data
148 // If IP/user changed, signal frontend to reset session instead of blocking
149 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
150
151 // Check if this session has an owner recorded
152 $session_owner = get_option("mxchat_session_owner_{$session_id}");
153
154 // If session has an owner and it doesn't match current user, trigger session reset
155 if ($session_owner && $session_owner !== $current_user_identifier) {
156 wp_send_json_error([
157 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
158 'code' => 'session_expired',
159 'action' => 'reset_session'
160 ]);
161 wp_die();
162 }
163
164 // If no owner is set yet, claim ownership (for legacy sessions)
165 if (!$session_owner) {
166 update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
167 }
168
169 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
170 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
171
172 if (empty($history)) {
173 // Even if history is empty, return the chat mode
174 wp_send_json_success([
175 'conversation' => [],
176 'chat_mode' => $chat_mode
177 ]);
178 wp_die();
179 }
180
181 wp_send_json_success([
182 'conversation' => $history,
183 'chat_mode' => $chat_mode
184 ]);
185 wp_die();
186 }
187 private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
188 $history = get_option("mxchat_history_{$session_id}", []);
189
190 // Check persistence setting - when OFF, only include messages from current page load
191 $options = get_option('mxchat_options', []);
192 $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
193
194 // Filter history when persistence is OFF to match what the user sees
195 if (!$persistence_enabled && $session_start_timestamp > 0) {
196 $history = array_filter($history, function($entry) use ($session_start_timestamp) {
197 // Include messages from this page load onwards
198 return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
199 });
200 // Re-index array after filtering
201 $history = array_values($history);
202 }
203
204 $formatted_history = [];
205
206 // Adjusted for code-heavy conversations
207 $max_tokens = 120000; // Context window size
208 $reserved_tokens = 5000; // Space for system prompts + current query
209 $current_token_count = 0;
210
211 // Allowed HTML tags for content sanitization
212 $allowed_tags = [
213 'pre' => ['class' => true],
214 'code' => ['class' => true],
215 'span' => ['class' => true],
216 'div' => ['class' => true],
217 'strong' => [],
218 'em' => []
219 ];
220
221 foreach (array_reverse($history) as $entry) {
222 // Preserve code blocks while sanitizing other HTML
223 $clean_content = wp_kses($entry['content'], $allowed_tags);
224
225 // Detect code blocks in content
226 $has_code = false;
227 // Replace the HTML check with:
228 // Allow messages that contain code blocks or are plain text
229 if (strpos($clean_content, '<pre') === false &&
230 strpos($clean_content, '<code') === false &&
231 $clean_content !== strip_tags($entry['content'])) {
232 continue;
233 }
234
235 // Skip entries that lost significant content during sanitization
236 if (!$has_code && $clean_content !== strip_tags($entry['content'])) {
237 continue;
238 }
239
240 // More accurate token estimation (1 token ≈ 4 characters)
241 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
242
243 // Check token budget with the new estimate
244 if (($current_token_count + $token_estimate + $reserved_tokens) > $max_tokens) {
245 // Try to fit partial content if it's the first entry
246 if (empty($formatted_history)) {
247 $clean_content = mb_substr($clean_content, 0, ($max_tokens - $reserved_tokens) * 4);
248 $token_estimate = ceil(mb_strlen($clean_content, 'UTF-8') / 4);
249 } else {
250 break;
251 }
252 }
253
254 // Add to formatted history
255 $formatted_history[] = [
256 'role' => $entry['role'],
257 'content' => $clean_content
258 ];
259
260 $current_token_count += $token_estimate;
261 }
262
263 // Reverse back to maintain chronological order
264 $formatted_history = array_reverse($formatted_history);
265
266 // Add system message about code context
267 array_unshift($formatted_history, [
268 'role' => 'system',
269 'content' => 'Preserved code blocks are marked with [CODE BLOCK PRESERVED]. '
270 . 'Maintain formatting and syntax highlighting when referencing code.'
271 ]);
272
273 return $formatted_history;
274 }
275
276 public function register_routes() {
277 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
278
279 register_rest_route('mxchat/v1', '/stream', [
280 'methods' => 'GET',
281 'callback' => [$this, 'mxchat_stream_events'],
282 'permission_callback' => [$this, 'verify_chat_session'],
283 ]);
284
285 register_rest_route('mxchat/v1', '/agent-response', [
286 'methods' => 'POST',
287 'callback' => [$this, 'mxchat_handle_agent_response'],
288 'permission_callback' => [$this, 'verify_slack_request'],
289 ]);
290
291 register_rest_route('mxchat/v1', '/slack-interaction', [
292 'methods' => 'POST',
293 'callback' => [$this, 'handle_slack_interaction'],
294 'permission_callback' => [$this, 'verify_slack_request'],
295 ]);
296
297 register_rest_route('mxchat/v1', '/slack-messages', [
298 'methods' => 'POST',
299 'callback' => [$this, 'handle_slack_messages'],
300 'permission_callback' => [$this, 'verify_slack_request'],
301 ]);
302
303 // Telegram webhook endpoint
304 register_rest_route('mxchat/v1', '/telegram-webhook', [
305 'methods' => 'POST',
306 'callback' => [$this, 'handle_telegram_webhook'],
307 'permission_callback' => [$this, 'verify_telegram_request'],
308 ]);
309
310 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
311 }
312
313 /**
314 * Verify valid chat session
315 */
316 public function verify_chat_session($request) {
317 $session_id = $request->get_param('session_id');
318 if (empty($session_id)) {
319 //error_log(esc_html__('Empty session ID in chat request', 'mxchat'));
320 return false;
321 }
322
323 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
324 return $chat_mode === 'agent';
325 }
326
327 /**
328 * Verify request is coming from Slack.
329 *
330 * @param WP_REST_Request $request
331 * @return bool True if valid, false otherwise.
332 */
333 public function verify_slack_request($request) {
334 // Get the Slack signing secret from your plugin options
335 $valid_key = $this->options['live_agent_secret_key'] ?? '';
336
337 if (empty($valid_key)) {
338 //error_log(esc_html__('Slack signing secret not configured', 'mxchat'));
339 return false;
340 }
341
342 $timestamp = $request->get_header('X-Slack-Request-Timestamp');
343 $slack_signature = $request->get_header('X-Slack-Signature');
344
345 // Verify timestamp to prevent replay attacks
346 if (abs(time() - intval($timestamp)) > 300) {
347 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
348 return false;
349 }
350
351 // Get raw request body
352 $request_body = file_get_contents('php://input');
353
354 // Create the signature base string
355 $sig_basestring = "v0:{$timestamp}:{$request_body}";
356
357 // Calculate expected signature
358 $my_signature = 'v0=' . hash_hmac('sha256', $sig_basestring, $valid_key);
359
360 // Compare signatures
361 return hash_equals($my_signature, $slack_signature);
362 }
363
364 /**
365 * Verify request is coming from Telegram.
366 *
367 * @param WP_REST_Request $request
368 * @return bool True if valid, false otherwise.
369 */
370 public function verify_telegram_request($request) {
371 $secret_token = $this->options['telegram_webhook_secret'] ?? '';
372
373 error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
374 error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
375
376 if (empty($secret_token)) {
377 // If no secret is configured, allow the request (for initial setup)
378 error_log('[MxChat Telegram DEBUG] No secret configured, allowing request');
379 return true;
380 }
381
382 // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
383 $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
384
385 error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
386
387 if (empty($request_token)) {
388 error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
389 return false;
390 }
391
392 // Timing-safe comparison
393 $result = hash_equals($secret_token, $request_token);
394 error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
395 return $result;
396 }
397
398 public function mxchat_stream_events(WP_REST_Request $request) {
399 header('Content-Type: text/event-stream');
400 header('Cache-Control: no-cache');
401 header('Connection: keep-alive');
402
403 $session_id = sanitize_text_field($request->get_param('session_id'));
404 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
405
406 if (empty($session_id)) {
407 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
408 flush();
409 exit;
410 }
411
412 $history = get_option("mxchat_history_{$session_id}", []);
413
414 // Filter only new messages
415 $new_messages = array_filter($history, function ($message) use ($last_seen_id) {
416 return !empty($message['id']) && $message['id'] > $last_seen_id;
417 });
418
419 // Send new messages if available
420 if (!empty($new_messages)) {
421 echo esc_html__("event: newMessages\ndata: ", 'mxchat') . json_encode(array_values($new_messages)) . "\n\n";
422 } else {
423 // Keep the connection alive
424 echo esc_html__("event: keepAlive\ndata: ", 'mxchat') . "{}\n\n";
425 }
426 flush();
427 exit;
428 }
429
430
431
432
433 private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
434 global $wpdb;
435 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
436 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
437
438 // Check if this is the first message in a new session (before any other database operations)
439 $is_new_session = false;
440 if ($role === 'user') { // Only check for user messages, not bot responses
441 $existing_messages = $wpdb->get_var($wpdb->prepare(
442 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
443 $session_id
444 ));
445 $is_new_session = ($existing_messages == 0);
446
447 // Log for debugging
448 if ($is_new_session) {
449 //error_log("[DEBUG] This is a NEW session - first message");
450 }
451 }
452
453 // SECURITY FIX: Set session ownership for new sessions
454 if ($is_new_session && $role === 'user') {
455 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
456 $session_owner_key = "mxchat_session_owner_{$session_id}";
457
458 // Only set ownership if not already set
459 if (!get_option($session_owner_key)) {
460 update_option($session_owner_key, $current_user_identifier, 'no');
461 //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
462 }
463 }
464
465 // 1) Extract agent name if present
466 $agent_name = '';
467 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
468 $agent_name = $matches[1];
469 $message = str_replace("Agent: $agent_name - ", '', $message);
470 $session_meta_key = "mxchat_agent_name_{$session_id}";
471 if (empty(get_option($session_meta_key))) {
472 update_option($session_meta_key, $agent_name);
473 //error_log("[DEBUG] mxchat_save_chat_message -> Stored agent_name in option: {$session_meta_key} => {$agent_name}");
474 }
475 }
476
477 // 2) Generate unique message_id
478 $message_id = uniqid();
479 //error_log("[DEBUG] mxchat_save_chat_message -> Generated message_id: {$message_id}");
480
481 // 3) Determine user_id
482 $user_id = is_user_logged_in() ? get_current_user_id() : 0;
483
484 // 4) Determine user_identifier
485 $user_identifier = $agent_name
486 ? $agent_name
487 : MxChat_User::mxchat_get_user_identifier();
488
489 // 5) Determine displayed_name
490 $user_email = MxChat_User::mxchat_get_user_email();
491 $displayed_name = $agent_name ? $agent_name : ($user_email ?: $user_identifier);
492
493 // 6) Check for a saved email in wp_options
494 $email_option_key = "mxchat_email_{$session_id}";
495 $saved_email = get_option($email_option_key);
496 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
497
498 // Check for a saved name in wp_options
499 $name_option_key = "mxchat_name_{$session_id}";
500 $saved_name = get_option($name_option_key);
501 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
502
503 // If found, update DB user_email and user_name
504 if ($saved_email || $saved_name) {
505 $update_data = [];
506 if ($saved_email) {
507 $update_data['user_email'] = $saved_email;
508 }
509 if ($saved_name) {
510 $update_data['user_name'] = $saved_name;
511 }
512
513 if (!empty($update_data)) {
514 $update_res = $wpdb->update(
515 $table_name,
516 $update_data,
517 ['session_id' => $session_id],
518 array_fill(0, count($update_data), '%s'),
519 ['%s']
520 );
521 //error_log("[DEBUG] mxchat_save_chat_message -> Attempted DB user_email/user_name update for session_id {$session_id}. update_res: {$update_res}");
522 }
523 }
524
525 // 7) Save to session history in wp_options
526 $history_key = "mxchat_history_{$session_id}";
527 $history = get_option($history_key, []);
528 $history[] = [
529 'id' => $message_id,
530 'role' => $role,
531 'content' => $message,
532 'timestamp' => round(microtime(true) * 1000),
533 'agent_name' => $displayed_name,
534 ];
535 update_option($history_key, $history, 'no');
536 //error_log("[DEBUG] mxchat_save_chat_message -> Updated session history in option: {$history_key}");
537
538 // 8) Save the message to DB (INSERT)
539 $insert_data = [
540 'user_id' => $user_id,
541 'user_identifier'=> $user_identifier,
542 'user_email' => $saved_email ?: $user_email,
543 'user_name' => $saved_name ?: '', // Add name to insert data
544 'session_id' => $session_id,
545 'role' => $role,
546 'message' => $message,
547 'timestamp' => current_time('mysql', 1),
548 ];
549
550 // IMPROVED: Handle originating page data
551 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
552
553 if ($columns_exist) {
554 if ($is_new_session && $role === 'user') {
555 // For the first user message, set originating page data
556
557 // First check if we have it from the parameter
558 if ($originating_page && !empty($originating_page['url'])) {
559 $insert_data['originating_page_url'] = $originating_page['url'];
560 $insert_data['originating_page_title'] = $originating_page['title'] ?? '';
561
562 //error_log("[DEBUG] Setting originating page from parameter: " . $originating_page['url']);
563 }
564 // Otherwise check if it's stored in the instance property
565 else if (isset($this->pending_originating_page) && !empty($this->pending_originating_page['url'])) {
566 $insert_data['originating_page_url'] = $this->pending_originating_page['url'];
567 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
568
569 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
570
571 // Clear after using
572 unset($this->pending_originating_page);
573 }
574 // Fallback to HTTP_REFERER if nothing else is available
575 else if (isset($_SERVER['HTTP_REFERER'])) {
576 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
577 $insert_data['originating_page_url'] = $referer_url;
578
579 // Generate title from URL
580 $parsed_url = parse_url($referer_url);
581 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
582
583 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
584 $insert_data['originating_page_title'] = 'Homepage';
585 } else {
586 $title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
587 $insert_data['originating_page_title'] = ucwords(trim($title));
588 }
589
590 //error_log("[DEBUG] Setting originating page from HTTP_REFERER: " . $referer_url);
591 }
592
593 // Store for this session so all messages have the same originating page
594 if (!empty($insert_data['originating_page_url'])) {
595 update_option("mxchat_originating_page_{$session_id}", [
596 'url' => $insert_data['originating_page_url'],
597 'title' => $insert_data['originating_page_title']
598 ], 'no');
599 }
600 } else {
601 // For subsequent messages in the session, use the stored originating page
602 $stored_originating = get_option("mxchat_originating_page_{$session_id}");
603 if ($stored_originating && !empty($stored_originating['url'])) {
604 $insert_data['originating_page_url'] = $stored_originating['url'];
605 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
606 }
607 }
608 }
609
610 // Add RAG context if provided (for bot messages)
611 if ($rag_context !== null && $role === 'bot') {
612 $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
613 if ($rag_context_column_exists) {
614 $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
615 }
616 }
617
618 $wpdb->insert($table_name, $insert_data);
619 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
620
621 // 9) Send notification email if this is the first user message in a new session
622 if ($wpdb->insert_id && $is_new_session && $role === 'user') {
623 $this->send_new_chat_notification($session_id, array(
624 'identifier' => $user_identifier,
625 'email' => $saved_email ?: $user_email,
626 'ip' => $_SERVER['REMOTE_ADDR']
627 ));
628 }
629
630 // 10) Schedule delayed transcript email if enabled and message is from user
631 if ($wpdb->insert_id && $role === 'user') {
632 $this->schedule_delayed_transcript_email($session_id);
633 }
634
635 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
636 return $message_id;
637 }
638
639 private function send_new_chat_notification($session_id, $user_info = array()) {
640 $options = get_option('mxchat_transcripts_options');
641
642 // Check if notifications are enabled
643 if (empty($options['mxchat_enable_notifications'])) {
644 return false;
645 }
646
647 // Get notification email
648 $to = !empty($options['mxchat_notification_email']) ?
649 $options['mxchat_notification_email'] :
650 get_option('admin_email');
651
652 if (!is_email($to)) {
653 return false;
654 }
655
656 // Prepare email content
657 $subject = sprintf('[%s] New Chat Session Started', get_bloginfo('name'));
658
659 $user_identifier = isset($user_info['identifier']) ? $user_info['identifier'] : 'Guest';
660 $user_email = isset($user_info['email']) ? $user_info['email'] : 'Not provided';
661 $user_ip = isset($user_info['ip']) ? $user_info['ip'] : $_SERVER['REMOTE_ADDR'];
662
663 $message = sprintf(
664 "A new chat session has started on your website.\n\n" .
665 "Session ID: %s\n" .
666 "User: %s\n" .
667 "Email: %s\n" .
668 "IP Address: %s\n" .
669 "Time: %s\n\n" .
670 "View transcripts: %s",
671 $session_id,
672 $user_identifier,
673 $user_email,
674 $user_ip,
675 current_time('mysql'),
676 admin_url('admin.php?page=mxchat-transcripts')
677 );
678
679 // Send email
680 return wp_mail($to, $subject, $message);
681 }
682
683 /**
684 * Schedule delayed transcript email for a session
685 * Reschedules if a new user message is received
686 */
687 private function schedule_delayed_transcript_email($session_id) {
688 $options = get_option('mxchat_transcripts_options');
689
690 // Check if auto-email is enabled
691 if (empty($options['mxchat_auto_email_transcript_enabled'])) {
692 return;
693 }
694
695 // Get notification email
696 $email = !empty($options['mxchat_notification_email']) ?
697 $options['mxchat_notification_email'] :
698 get_option('admin_email');
699
700 if (!is_email($email)) {
701 return;
702 }
703
704 // Get delay in minutes (default 30)
705 $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
706 intval($options['mxchat_auto_email_transcript_delay']) : 30;
707
708 // Clear any existing scheduled event for this session
709 $hook = 'mxchat_send_delayed_transcript';
710 $args = array($session_id);
711 $timestamp = wp_next_scheduled($hook, $args);
712
713 if ($timestamp) {
714 wp_unschedule_event($timestamp, $hook, $args);
715 }
716
717 // Schedule new event
718 $schedule_time = time() + ($delay_minutes * 60);
719 wp_schedule_single_event($schedule_time, $hook, $args);
720 }
721
722 /**
723 * Check if chat messages contain contact information (email or phone number)
724 *
725 * @param array $messages Array of message objects with 'message' property
726 * @param object|null $session_data Session data object with user_email property
727 * @return bool True if contact info found, false otherwise
728 */
729 private function chat_contains_contact_info($messages, $session_data = null) {
730 // Check if session already has a stored email
731 if ($session_data && !empty($session_data->user_email)) {
732 return true;
733 }
734
735 // Email regex pattern
736 $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
737
738 // Phone number patterns (covers various formats including international, WhatsApp style)
739 // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
740 $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
741
742 // Only check user messages (not assistant responses)
743 foreach ($messages as $msg) {
744 if ($msg->role !== 'user') {
745 continue;
746 }
747
748 $message_text = $msg->message;
749
750 // Check for email
751 if (preg_match($email_pattern, $message_text)) {
752 return true;
753 }
754
755 // Check for phone number (must be at least 7 digits total to avoid false positives)
756 if (preg_match($phone_pattern, $message_text, $matches)) {
757 // Count actual digits to avoid matching short numbers
758 $digits_only = preg_replace('/\D/', '', $matches[0]);
759 if (strlen($digits_only) >= 7) {
760 return true;
761 }
762 }
763 }
764
765 return false;
766 }
767
768 /**
769 * Send the delayed transcript email with .txt attachment
770 */
771 public function mxchat_send_delayed_transcript($session_id) {
772 global $wpdb;
773
774 $options = get_option('mxchat_transcripts_options');
775
776 // Get notification email
777 $to = !empty($options['mxchat_notification_email']) ?
778 $options['mxchat_notification_email'] :
779 get_option('admin_email');
780
781 if (!is_email($to)) {
782 return false;
783 }
784
785 // Get all messages for this session
786 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
787 $messages = $wpdb->get_results($wpdb->prepare(
788 "SELECT role, message, timestamp FROM {$table_name}
789 WHERE session_id = %s
790 ORDER BY timestamp ASC",
791 $session_id
792 ));
793
794 if (empty($messages)) {
795 return false;
796 }
797
798 // Get session metadata
799 $sessions_table = $wpdb->prefix . 'mxchat_sessions';
800 $session_data = $wpdb->get_row($wpdb->prepare(
801 "SELECT * FROM {$sessions_table} WHERE session_id = %s",
802 $session_id
803 ));
804
805 // Check if contact info is required and if it's present
806 $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
807 if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
808 // Contact info required but not found - skip sending
809 return false;
810 }
811
812 // Build transcript content
813 $transcript_content = "Chat Transcript\n";
814 $transcript_content .= "================\n\n";
815 $transcript_content .= "Session ID: " . $session_id . "\n";
816
817 if ($session_data) {
818 $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
819 $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
820 $transcript_content .= "Started: " . $session_data->created_at . "\n";
821 }
822
823 $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
824
825 // Add messages
826 foreach ($messages as $msg) {
827 $role_label = ($msg->role === 'user') ? 'User' : 'Assistant';
828 $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
829 $transcript_content .= $msg->message . "\n\n";
830 }
831
832 // Create temporary file for attachment
833 $upload_dir = wp_upload_dir();
834 $temp_file = $upload_dir['basedir'] . '/mxchat-transcript-' . $session_id . '.txt';
835 file_put_contents($temp_file, $transcript_content);
836
837 // Prepare email
838 $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
839
840 $message = "Please find attached the full chat transcript.\n\n";
841 $message .= "Session ID: {$session_id}\n";
842
843 if ($session_data) {
844 $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
845 $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
846 }
847
848 $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
849
850 // Send email with attachment
851 $attachments = array($temp_file);
852 $result = wp_mail($to, $subject, $message, '', $attachments);
853
854 // Clean up temporary file
855 if (file_exists($temp_file)) {
856 unlink($temp_file);
857 }
858
859 return $result;
860 }
861
862
863
864 public function mxchat_handle_save_email_and_response() {
865 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
866 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
867
868 // Validate nonce
869 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
870 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
871 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
872 wp_die();
873 }
874
875 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
876 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
877 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
878
879 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
880
881 if (empty($session_id) || empty($email)) {
882 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
883 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
884 wp_die();
885 }
886
887 // Validate name if provided (check if name field is enabled and name is required)
888 $options = get_option('mxchat_options', []);
889 $name_field_enabled = isset($options['enable_name_field']) &&
890 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
891
892 if ($name_field_enabled && (empty($name) || strlen(trim($name)) < 2 || strlen(trim($name)) > 100)) {
893 //error_log("[ERROR] Invalid name: {$name} (enabled: {$name_field_enabled})");
894 wp_send_json_error(['message' => esc_html__('Name must be between 2 and 100 characters.', 'mxchat')]);
895 wp_die();
896 }
897
898 // 1) Always store email in wp_options
899 $email_option_key = "mxchat_email_{$session_id}";
900 update_option($email_option_key, $email);
901 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
902
903 // Store name in wp_options if provided
904 if (!empty($name)) {
905 $name_option_key = "mxchat_name_{$session_id}";
906 update_option($name_option_key, $name);
907 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
908 }
909
910 // 2) (Optional) Also store in DB if a row already exists
911 global $wpdb;
912 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
913
914 // Make sure we have a valid placeholder in prepare
915 $sql = $wpdb->prepare("SELECT COUNT(*) FROM {$table_name} WHERE session_id = %s", $session_id);
916 $session_count = $wpdb->get_var($sql);
917
918 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
919
920 if ($session_count) {
921 // Update both user_email and user_name if row(s) exist
922 if (!empty($name)) {
923 $update_sql = $wpdb->prepare(
924 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
925 $email,
926 $name,
927 $session_id
928 );
929 } else {
930 $update_sql = $wpdb->prepare(
931 "UPDATE {$table_name} SET user_email = %s WHERE session_id = %s",
932 $email,
933 $session_id
934 );
935 }
936 $wpdb->query($update_sql);
937 //error_log("[DEBUG] handle_save_email_and_response -> DB updated: {$update_sql}");
938 } else {
939 //error_log("[INFO] handle_save_email_and_response -> No DB entry for {$session_id}, so email/name is only in wp_options.");
940 }
941
942 // Provide success response (same as original)
943 $bot_message = __('Thanks for providing your email! You can continue chatting now.', 'mxchat');
944 //error_log("[DEBUG] handle_save_email_and_response -> success, returning bot_message: {$bot_message}");
945 wp_send_json_success(['message' => $bot_message]);
946 wp_die();
947 }
948
949 public function mxchat_check_email_provided() {
950 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
951
952 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
953 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
954 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
955 }
956
957 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
958 if (empty($session_id)) {
959 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
960 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
961 }
962
963 // Check if the user is logged in
964 if (is_user_logged_in()) {
965 $current_user = wp_get_current_user();
966 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
967
968 // Get user's display name for logged in users
969 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
970 (!empty($current_user->first_name) ? $current_user->first_name : '');
971
972 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
973 if (!empty($user_name)) {
974 $response_data['name'] = $user_name;
975 }
976
977 wp_send_json_success($response_data);
978 }
979
980 // Check if name field is required
981 $options = get_option('mxchat_options', []);
982 $name_field_enabled = isset($options['enable_name_field']) &&
983 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
984
985 $email_option_key = "mxchat_email_{$session_id}";
986 $stored_email = get_option($email_option_key, '');
987
988 // Check for stored name
989 $name_option_key = "mxchat_name_{$session_id}";
990 $stored_name = get_option($name_option_key, '');
991
992 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
993 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
994
995 // Check if we have email and name (if name is required)
996 $has_required_info = !empty($stored_email);
997
998 if ($name_field_enabled) {
999 $has_required_info = $has_required_info && !empty($stored_name);
1000 }
1001
1002 if ($has_required_info) {
1003 //error_log("[DEBUG] mxchat_check_email_provided -> Required info found, returning success");
1004
1005 $response_data = ['email' => $stored_email];
1006 if (!empty($stored_name)) {
1007 $response_data['name'] = $stored_name;
1008 }
1009
1010 wp_send_json_success($response_data);
1011 } else {
1012 //error_log("[DEBUG] mxchat_check_email_provided -> Required info missing, returning error");
1013 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1014 }
1015 }
1016
1017 /**
1018 * Send error response in appropriate format based on streaming mode
1019 * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1020 *
1021 * @param string $error_message The error message to display
1022 * @param string $error_code Optional error code for debugging
1023 */
1024 private function send_error_response($error_message, $error_code = 'api_error') {
1025 if ($this->is_streaming) {
1026 echo "data: " . json_encode([
1027 'error' => true,
1028 'error_message' => $error_message,
1029 'error_code' => $error_code,
1030 'text' => $error_message,
1031 'message' => $error_message
1032 ]) . "\n\n";
1033 echo "data: [DONE]\n\n";
1034 flush();
1035 } else {
1036 wp_send_json_error([
1037 'error_message' => $error_message,
1038 'error_code' => $error_code
1039 ]);
1040 }
1041 wp_die();
1042 }
1043
1044 public function mxchat_handle_chat_request() {
1045 global $wpdb;
1046
1047 // Debug: Log incoming bot_id
1048 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1049 error_log("=== MXCHAT DEBUG: Starting chat request ===");
1050 error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
1051
1052 // Get bot-specific options
1053 $bot_options = $this->get_bot_options($bot_id);
1054 $current_options = !empty($bot_options) ? $bot_options : $this->options;
1055
1056 // Check if this is a streaming request
1057 // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1058 $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1059 $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1060 ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1061
1062 // ADDED: Store streaming state in class property for use in private methods
1063 $this->is_streaming = $is_streaming;
1064
1065 // NOTE: Streaming headers are now set later via setup_streaming_headers()
1066 // This allows actions/forms to return JSON responses without header conflicts
1067
1068 // Check if MX Chat Moderation is active
1069 if (class_exists('MX_Chat_Moderation')) {
1070 // Get user email and IP
1071 $user_email = '';
1072 $user_ip = $_SERVER['REMOTE_ADDR'];
1073
1074 // If user is logged in, get their email
1075 if (is_user_logged_in()) {
1076 $current_user = wp_get_current_user();
1077 $user_email = $current_user->user_email;
1078 }
1079
1080 // Create ban handler instance
1081 $ban_handler = new MX_Chat_Ban_Handler();
1082
1083 // Check if user is banned by IP
1084 if ($ban_handler->check_ban($user_ip, 'ip')) {
1085 wp_send_json([
1086 'success' => false,
1087 'message' => esc_html__('Access denied. Your IP address has been banned.', 'mxchat'),
1088 'status' => 'banned'
1089 ]);
1090 wp_die();
1091 }
1092
1093 // If user is logged in, also check email
1094 if (!empty($user_email) && $ban_handler->check_ban($user_email, 'email')) {
1095 wp_send_json([
1096 'success' => false,
1097 'message' => esc_html__('Access denied. Your email address has been banned.', 'mxchat'),
1098 'status' => 'banned'
1099 ]);
1100 wp_die();
1101 }
1102 }
1103
1104 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1105 $this->productCardHtml = '';
1106
1107 // Get the actual WordPress user ID if logged in
1108 $is_logged_in = is_user_logged_in();
1109 if ($is_logged_in) {
1110 $user_id = get_current_user_id(); // This will get the actual WordPress user ID
1111 } else {
1112 // For logged-out users, use your existing identifier method
1113 $user_id = $this->mxchat_get_user_identifier();
1114 }
1115
1116 // Get and sanitize the user identifier
1117 $user_id = sanitize_key($user_id);
1118
1119 // Check rate limit using new settings structure
1120 $rate_limit_result = $this->check_rate_limit();
1121
1122 if ($rate_limit_result !== true) {
1123 wp_send_json([
1124 'success' => false,
1125 'message' => $rate_limit_result['message'],
1126 'status' => 'rate_limit_exceeded'
1127 ]);
1128 wp_die();
1129 }
1130
1131 // Rest of your existing code...
1132 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1133
1134 if (empty($session_id)) {
1135 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1136 wp_die();
1137 }
1138
1139 // SECURITY FIX: Verify session ownership before processing chat request
1140 // If IP/user changed, signal frontend to reset session instead of blocking
1141 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1142 $session_owner = get_option("mxchat_session_owner_{$session_id}");
1143
1144 if ($session_owner && $session_owner !== $current_user_identifier) {
1145 // Instead of blocking, tell frontend to start a fresh session
1146 wp_send_json_error([
1147 'message' => esc_html__('Your session has expired. Starting a new conversation.', 'mxchat'),
1148 'code' => 'session_expired',
1149 'action' => 'reset_session'
1150 ]);
1151 wp_die();
1152 }
1153
1154 // Validate and sanitize the incoming message
1155 if (empty($_POST['message'])) {
1156 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1157 wp_die();
1158 }
1159
1160
1161 // Track originating page for first message in session
1162 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1163
1164 // Check if originating page columns exist
1165 $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1166
1167 if ($columns_exist) {
1168 // Check if this session already has messages
1169 $message_count = $wpdb->get_var($wpdb->prepare(
1170 "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1171 $session_id
1172 ));
1173
1174 // If this is the first message in the session
1175 if ($message_count == 0) {
1176 // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1177 $originating_url = '';
1178 $originating_title = '';
1179
1180 // Try to get from POST data first (sent by JavaScript)
1181 if (isset($_POST['current_page_url'])) {
1182 $originating_url = esc_url_raw($_POST['current_page_url']);
1183 $originating_title = isset($_POST['current_page_title'])
1184 ? sanitize_text_field($_POST['current_page_title'])
1185 : '';
1186 }
1187 // Fallback to HTTP_REFERER if not provided by JavaScript
1188 else if (isset($_SERVER['HTTP_REFERER'])) {
1189 $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1190 }
1191
1192 // Generate title if we have URL but no title
1193 if ($originating_url && empty($originating_title)) {
1194 $parsed_url = parse_url($originating_url);
1195 $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1196
1197 if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1198 $originating_title = 'Homepage';
1199 } else {
1200 // Clean up the path to make a readable title
1201 $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1202 $originating_title = ucwords(trim($originating_title));
1203 }
1204 }
1205
1206 // Store for later use when saving the message
1207 $this->pending_originating_page = [
1208 'url' => $originating_url,
1209 'title' => $originating_title
1210 ];
1211 }
1212 }
1213
1214
1215
1216 // Get page context if provided
1217 $page_context = null;
1218 if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1219 $page_context_raw = stripslashes($_POST['page_context']);
1220 $page_context = json_decode($page_context_raw, true);
1221
1222 // Validate page context structure
1223 if (is_array($page_context) &&
1224 isset($page_context['url']) &&
1225 isset($page_context['title']) &&
1226 isset($page_context['content'])) {
1227
1228 // Sanitize page context
1229 $page_context['url'] = esc_url_raw($page_context['url']);
1230 $page_context['title'] = sanitize_text_field($page_context['title']);
1231 $page_context['content'] = wp_kses_post($page_context['content']);
1232 } else {
1233 $page_context = null;
1234 }
1235 }
1236
1237 // Modify the message sanitization to preserve PHP tags in code blocks
1238 $allowed_tags = [
1239 'pre' => [],
1240 'code' => ['class' => true],
1241 'span' => ['class' => true],
1242 'div' => ['class' => true],
1243 ];
1244
1245 // First preserve code blocks
1246 $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1247 return htmlspecialchars_decode($matches[0]);
1248 }, $_POST['message']);
1249
1250 // Then apply sanitization
1251 $message = wp_kses($message, $allowed_tags);
1252
1253 // Preserve code blocks from markdown conversion
1254 $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1255 $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1256
1257 // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1258 // Always initialize testing data for admins (no toggle needed)
1259 $testing_data = null;
1260 if (current_user_can('administrator')) {
1261 // For vision messages, use the original user message for the query display
1262 $query_for_testing = $message;
1263 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1264 $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1265 }
1266
1267 $testing_data = [
1268 'query' => $query_for_testing,
1269 'timestamp' => time(),
1270 'top_matches' => [],
1271 'action_matches' => [], // Initialize action matches array
1272 'page_context' => $page_context, // Include page context in testing data
1273 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1274 'bot_id' => $bot_id // Include bot ID in testing data
1275 ];
1276
1277 // Get similarity threshold from bot options or default options
1278 $similarity_threshold = isset($current_options['similarity_threshold'])
1279 ? ((int) $current_options['similarity_threshold']) / 100
1280 : 0.35;
1281
1282 $testing_data['similarity_threshold'] = $similarity_threshold;
1283
1284 // Determine knowledge base type using bot-specific config
1285 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1286 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1287 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
1288 }
1289 // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1290
1291 // Add debug before and after:
1292 //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1293 $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1294 //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1295
1296
1297 // If the pre-processing returned a result (not the original message), use it directly
1298 if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1299 // Save the AI response
1300 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1301
1302 // Save HTML content if provided
1303 if (!empty($pre_processed_result['html'])) {
1304 $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1305 }
1306
1307 // Add testing data if admin
1308 $response_data = [
1309 'text' => $pre_processed_result['text'],
1310 'html' => $pre_processed_result['html'] ?? '',
1311 'session_id' => $session_id
1312 ];
1313
1314 if ($testing_data !== null) {
1315 $response_data['testing_data'] = $testing_data;
1316 }
1317
1318 wp_send_json($response_data);
1319 wp_die();
1320 }
1321
1322 // Save the user's message - handle vision processed messages differently
1323 if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1324 // For vision messages, save the original user message with image indicator
1325 $original_message = sanitize_textarea_field($_POST['original_user_message']);
1326 if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1327 $image_count = intval($_POST['vision_images_count']);
1328 $original_message .= " [{$image_count} image(s)]";
1329 }
1330 $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1331 } else {
1332 // Regular message - save as normal
1333 $this->mxchat_save_chat_message($session_id, 'user', $message);
1334 }
1335
1336
1337 if (is_email($message)) {
1338 // Add the email to Loops
1339 $this->add_email_to_loops($message);
1340
1341 // Get the user's success message instruction using current_options
1342 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1343
1344 // Set instruction for AI using the user's success message
1345 $this->current_action_instruction = $user_success_message;
1346
1347 // Clear the email capture transient since we got the email
1348 delete_transient('mxchat_email_capture_' . $user_id);
1349 }
1350
1351 // Check if we're in an email capture flow but user hasn't provided email yet
1352 elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1353 // Check if the message contains an email (not the whole message being an email)
1354 if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1355 $extracted_email = $matches[0];
1356
1357 // Add the extracted email to Loops
1358 $this->add_email_to_loops($extracted_email);
1359
1360 // Get the user's success message instruction using current_options
1361 $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1362
1363 // Set instruction for AI using the user's success message
1364 $this->current_action_instruction = $user_success_message;
1365
1366 // Clear the email capture transient since we got the email
1367 delete_transient('mxchat_email_capture_' . $user_id);
1368 }
1369 // If no email found but we're in capture mode, remind them
1370 else {
1371 // Get the original instruction to remind them using current_options
1372 $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1373 $this->current_action_instruction = $original_instruction;
1374 }
1375 }
1376
1377 $intent_info = '';
1378
1379 // Check chat mode
1380 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1381
1382 // Handle agent mode
1383 // Handle agent mode
1384 if ($chat_mode === 'agent') {
1385 // First, check for switch intent before doing anything else
1386 $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1387
1388 // Capture action analysis for testing panel after intent check
1389 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1390 $testing_data['action_matches'] = $this->last_action_analysis;
1391 }
1392
1393 // Around line 506, in the agent mode handling section:
1394 if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1395 // Update chat mode first
1396 update_option("mxchat_mode_{$session_id}", 'ai');
1397
1398 // Clear any existing PDF context to start fresh
1399 $this->clear_pdf_transients($session_id);
1400
1401 // Prepare clean switch response with explicit chat_mode
1402 $response_data = [
1403 'text' => $this->fallbackResponse['text'],
1404 'html' => $this->fallbackResponse['html'] ?? '',
1405 'session_id' => $session_id,
1406 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1407 ];
1408
1409 if ($testing_data !== null) {
1410 $response_data['testing_data'] = $testing_data;
1411 }
1412
1413 // Save the mode switch message
1414 $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1415 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1416
1417 // Send response and exit
1418 wp_send_json($response_data);
1419 wp_die();
1420 } elseif (!$intent_matched) {
1421 // No intent matched, handle live agent message
1422 try {
1423 $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1424
1425 $agent_response = [
1426 'status' => 'waiting_for_agent',
1427 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1428 ];
1429
1430 if ($testing_data !== null) {
1431 $agent_response['testing_data'] = $testing_data;
1432 }
1433
1434 wp_send_json_success($agent_response);
1435 } catch (\Exception $e) {
1436 wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1437 }
1438 wp_die();
1439 }
1440 }
1441
1442 // Step 1: Check for new PDF URL in the message
1443 if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1444 $new_pdf_url = $matches[0];
1445
1446 // Check if this is likely a PDF-related request
1447 $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1448 $is_pdf_request = false;
1449
1450 foreach ($pdf_keywords as $keyword) {
1451 if (stripos($message, $keyword) !== false) {
1452 $is_pdf_request = true;
1453 break;
1454 }
1455 }
1456
1457 // If it looks like a PDF request or we're waiting for a PDF URL
1458 if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1459 // Validate HTTPS
1460 if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1461 // Extract filename from URL
1462 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1463
1464 // Clear previous PDF transients
1465 $this->clear_pdf_transients($session_id);
1466
1467 // Process new PDF using current_options
1468 $max_pages = $current_options['pdf_max_pages'] ?? 69;
1469 $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1470
1471 if ($embeddings === 'too_many_pages') {
1472 $error_text = sprintf(
1473 $current_options['pdf_intent_error_text'] ??
1474 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1475 $max_pages
1476 );
1477 $this->fallbackResponse['text'] = $error_text;
1478 } elseif ($embeddings) {
1479 // Store new PDF information
1480 $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1481
1482 // If the filename is generic, create a more descriptive one
1483 if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1484 strpos($pdf_filename, '.php') !== false) {
1485 $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1486 }
1487
1488 set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1489 set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1490 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1491 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1492
1493 $success_text = $current_options['pdf_intent_success_text'] ??
1494 esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1495
1496 $pdf_response = [
1497 'success' => true,
1498 'message' => $success_text,
1499 'data' => [
1500 'filename' => $pdf_filename
1501 ]
1502 ];
1503
1504 if ($testing_data !== null) {
1505 $pdf_response['testing_data'] = $testing_data;
1506 }
1507
1508 wp_send_json($pdf_response);
1509 wp_die();
1510 } else {
1511 $error_text = $current_options['pdf_intent_error_text'] ??
1512 esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1513 $this->fallbackResponse['text'] = $error_text;
1514 }
1515
1516 $pdf_error_response = [
1517 'success' => false,
1518 'message' => $this->fallbackResponse['text']
1519 ];
1520
1521 if ($testing_data !== null) {
1522 $pdf_error_response['testing_data'] = $testing_data;
1523 }
1524
1525 wp_send_json($pdf_error_response);
1526 wp_die();
1527 }
1528 }
1529 }
1530
1531
1532 // Step 2: Detect intent and handle intent-based responses
1533 $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1534
1535 // Capture action analysis for testing panel after intent check
1536 if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1537 $testing_data['action_matches'] = $this->last_action_analysis;
1538 }
1539
1540 // Step 3: Handle the intent result appropriately
1541 if ($intent_result !== false) {
1542 // Intent was matched - ALWAYS send as JSON response, never streaming
1543
1544 if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1545 // Intent returned a direct response array
1546 $response_data = [
1547 'text' => $intent_result['text'] ?? '',
1548 'html' => $intent_result['html'] ?? '',
1549 'session_id' => $session_id
1550 ];
1551
1552 // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
1553 if (isset($intent_result['chat_mode'])) {
1554 $response_data['chat_mode'] = $intent_result['chat_mode'];
1555 }
1556
1557 if ($testing_data !== null) {
1558 $response_data['testing_data'] = $testing_data;
1559 }
1560
1561 wp_send_json($response_data);
1562 wp_die();
1563 } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1564 // Intent returned true and set fallbackResponse
1565
1566 // SAVE TO TRANSCRIPT FIRST
1567 if (!empty($this->fallbackResponse['text'])) {
1568 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1569 }
1570 if (!empty($this->fallbackResponse['html'])) {
1571 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1572 }
1573
1574 $response_data = [
1575 'text' => $this->fallbackResponse['text'] ?? '',
1576 'html' => $this->fallbackResponse['html'] ?? '',
1577 'session_id' => $session_id
1578 ];
1579
1580 if (isset($this->fallbackResponse['chat_mode'])) {
1581 $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
1582 }
1583
1584 if ($testing_data !== null) {
1585 $response_data['testing_data'] = $testing_data;
1586 }
1587
1588 wp_send_json($response_data);
1589 wp_die();
1590 }
1591 }
1592
1593 // If we get here, no intent matched OR the intent didn't provide a usable response
1594
1595 // Step 4: Generate AI response
1596 // Get session start timestamp - when persistence is OFF, only include messages from this page load
1597 $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
1598 $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
1599 $this->mxchat_increment_chat_count();
1600
1601 // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
1602 $api_key = $current_options['api_key'] ?? $this->options['api_key'];
1603 $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1604
1605 // Check if the embedding generation returned an error
1606 if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1607 $error_message = $user_message_embedding['error'];
1608 $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
1609
1610 // FIXED: Send error in appropriate format based on streaming mode
1611 if ($is_streaming) {
1612 echo "data: " . json_encode([
1613 'error' => true,
1614 'error_message' => $error_message,
1615 'error_code' => $error_code,
1616 'text' => $error_message,
1617 'message' => $error_message
1618 ]) . "\n\n";
1619 echo "data: [DONE]\n\n";
1620 flush();
1621 } else {
1622 wp_send_json_error([
1623 'error_message' => $error_message,
1624 'error_code' => $error_code
1625 ]);
1626 }
1627 wp_die();
1628 }
1629
1630 // Check if the embedding is valid
1631 if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1632 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1633
1634 // FIXED: Send error in appropriate format based on streaming mode
1635 if ($is_streaming) {
1636 echo "data: " . json_encode([
1637 'error' => true,
1638 'error_message' => $error_message,
1639 'error_code' => 'invalid_embedding',
1640 'text' => $error_message,
1641 'message' => $error_message
1642 ]) . "\n\n";
1643 echo "data: [DONE]\n\n";
1644 flush();
1645 } else {
1646 wp_send_json_error([
1647 'error_message' => $error_message,
1648 'error_code' => 'invalid_embedding'
1649 ]);
1650 }
1651 wp_die();
1652 }
1653
1654 // Build context with both knowledge base and PDF content if available
1655 $context_content = "User asked: '{$message}'\n\n";
1656
1657 // Add action instruction if present (add this right after the above line)
1658 if (!empty($this->current_action_instruction)) {
1659 $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1660 $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1661 $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1662 $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1663
1664 // Clear the instruction after using it
1665 $this->current_action_instruction = null;
1666 }
1667
1668
1669 // Add page context if available and contextual awareness is enabled using current_options
1670 if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
1671 $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1672 $context_content .= "Page URL: " . $page_context['url'] . "\n";
1673 $context_content .= "Page Title: " . $page_context['title'] . "\n";
1674 $context_content .= "Page Content: " . $page_context['content'] . "\n";
1675 $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1676 }
1677
1678 // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
1679 $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
1680
1681 // NEW: Also extract URLs from system instructions (only if citation links enabled)
1682 // Use fresh options to ensure we get the latest setting value
1683 $fresh_options = get_option('mxchat_options', []);
1684 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
1685
1686 $system_instructions = $this->get_system_instructions($bot_id, $session_id);
1687 if ($citation_links_enabled && !empty($system_instructions)) {
1688 preg_match_all(
1689 '#\bhttps?://[^\s<>"\']+#i',
1690 $system_instructions,
1691 $system_instruction_urls
1692 );
1693
1694 if (!empty($system_instruction_urls[0])) {
1695 // Merge with existing valid URLs
1696 $this->current_valid_urls = array_merge(
1697 $this->current_valid_urls,
1698 $system_instruction_urls[0]
1699 );
1700 // Remove duplicates
1701 $this->current_valid_urls = array_unique($this->current_valid_urls);
1702
1703 error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1704 }
1705 }
1706
1707 // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1708 if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1709 // Update testing data with the REAL similarity analysis
1710 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1711 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1712 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1713 }
1714 // ===== END SIMILARITY DATA CAPTURE =====
1715
1716 // NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
1717 if ($testing_data !== null && !empty($this->current_valid_urls)) {
1718 $testing_data['approved_urls'] = array_values($this->current_valid_urls);
1719 error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
1720 }
1721
1722 if (!empty($relevant_content)) {
1723 $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1724 } else {
1725 $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1726 }
1727
1728 // NEW: Add approved URLs list to context for AI (only if citation links enabled)
1729 if ($citation_links_enabled && !empty($this->current_valid_urls)) {
1730 $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
1731 $context_content .= "You may ONLY use these exact URLs in your response:\n";
1732 foreach ($this->current_valid_urls as $url) {
1733 $context_content .= "- " . $url . "\n";
1734 }
1735 $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
1736 $context_content .= "===== END APPROVED URLS =====\n\n";
1737 }
1738
1739 // Check for and include PDF content
1740 $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1741 $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1742 $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1743 if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1744 $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1745 if (!empty($relevant_pdf_pages)) {
1746 $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1747 foreach ($relevant_pdf_pages as $page_data) {
1748 $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
1749 }
1750 $context_content .= "\n";
1751 }
1752 }
1753
1754 // Check for and include Word content
1755 $word_url = get_transient('mxchat_word_url_' . $session_id);
1756 $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1757 $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1758 if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1759 $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1760 if (!empty($relevant_word_chunks)) {
1761 $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1762 foreach ($relevant_word_chunks as $chunk_data) {
1763 $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
1764 }
1765 $context_content .= "\n";
1766 }
1767 }
1768
1769 $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1770
1771 // Extract model from current options for bot-specific model support
1772 $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.1-chat-latest';
1773
1774 $response = $this->mxchat_generate_response(
1775 $context_content,
1776 $current_options['api_key'] ?? $this->options['api_key'],
1777 $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
1778 $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
1779 $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
1780 $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
1781 $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
1782 $conversation_history,
1783 $is_streaming,
1784 $session_id,
1785 $testing_data,
1786 $selected_model
1787 );
1788
1789 // Handle streaming vs non-streaming responses
1790 if ($is_streaming) {
1791 // Check if streaming actually happened or if it fell back to regular response
1792 if ($response === true) {
1793 wp_die();
1794 }
1795 // If we get here, streaming fell back to regular response, continue
1796 // But if there's an error, we need to send it as SSE format since headers are already set
1797 if (is_array($response) && isset($response['error'])) {
1798 $error_message = $response['error'];
1799 $error_code = $response['error_code'] ?? 'api_error';
1800 // Send error in SSE format that the client JS can handle
1801 echo "data: " . json_encode([
1802 'error' => true,
1803 'error_message' => $error_message,
1804 'error_code' => $error_code,
1805 'text' => $error_message, // Also include as text for fallback handling
1806 'message' => $error_message
1807 ]) . "\n\n";
1808 echo "data: [DONE]\n\n";
1809 flush();
1810 wp_die();
1811 }
1812 }
1813
1814 // Check if the response is an error array (non-streaming mode)
1815 if (is_array($response) && isset($response['error'])) {
1816 wp_send_json_error([
1817 'error_message' => $response['error'],
1818 'error_code' => $response['error_code'] ?? 'api_error'
1819 ]);
1820 wp_die();
1821 }
1822
1823 // DEBUG: Check what we have
1824 error_log("=== BEFORE URL VALIDATION ===");
1825 error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
1826 error_log("current_valid_urls count: " . count($this->current_valid_urls));
1827 error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
1828
1829 // If we get here, the response is valid text - now validate URLs
1830 if (!empty($this->current_valid_urls)) {
1831 error_log("CALLING validate_and_clean_urls");
1832 $response = $this->validate_and_clean_urls($response, $this->current_valid_urls);
1833 } else {
1834 error_log("SKIPPING validation - current_valid_urls is empty");
1835 }
1836 // ===== END URL VALIDATION =====
1837
1838 // Prepare RAG context data for storage (only include documents used for context)
1839 $rag_context_for_storage = null;
1840 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
1841 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
1842
1843 if ($has_rag_data || $has_action_data) {
1844 $rag_context_for_storage = [];
1845
1846 // Add RAG/source data if available
1847 if ($has_rag_data) {
1848 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
1849 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
1850 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
1851 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
1852 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1853 }
1854
1855 // Add action analysis data if available
1856 if ($has_action_data) {
1857 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
1858 }
1859 }
1860
1861 // Save the cleaned response with RAG context
1862 $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
1863
1864 // Step 5: Save additional content if available
1865 if (!empty($this->productCardHtml)) {
1866 $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
1867 }
1868
1869 if (!empty($this->fallbackResponse['html'])) {
1870 $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
1871 }
1872
1873 // Step 6: Return the response
1874 // DEBUG: Check if newlines exist in the response
1875 error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
1876 error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
1877 error_log("Response first 500 chars: " . substr($response, 0, 500));
1878
1879 $response_data = [
1880 'text' => $response,
1881 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1882 'session_id' => $session_id
1883 ];
1884
1885 // Always add testing data for admins (no toggle needed)
1886 if ($testing_data !== null) {
1887 $response_data['testing_data'] = $testing_data;
1888 }
1889
1890 wp_send_json($response_data);
1891 wp_die();
1892 }
1893
1894 /**
1895 * Get bot-specific options for multi-bot functionality
1896 * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
1897 */
1898 // Also debug the bot options retrieval
1899 private function get_bot_options($bot_id = 'default') {
1900 error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
1901
1902 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1903 error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
1904 return array();
1905 }
1906
1907 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
1908
1909 if (!empty($bot_options)) {
1910 error_log("MXCHAT DEBUG: Got bot-specific options from filter");
1911 if (isset($bot_options['similarity_threshold'])) {
1912 error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
1913 }
1914 }
1915
1916 return is_array($bot_options) ? $bot_options : array();
1917 }
1918
1919 /**
1920 * Get bot-specific Pinecone configuration
1921 * Used in the knowledge retrieval functions
1922 */
1923 // Also add debugging to your get_bot_pinecone_config function
1924 private function get_bot_pinecone_config($bot_id = 'default') {
1925 error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1926
1927 // If default bot or multi-bot add-on not active, use default Pinecone config
1928 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1929 error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1930 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1931 $config = array(
1932 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1933 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1934 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1935 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1936 );
1937 error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1938 return $config;
1939 }
1940
1941 error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1942
1943 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1944 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1945
1946 if (!empty($bot_pinecone_config)) {
1947 error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1948 error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1949 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1950 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1951 } else {
1952 error_log("MXCHAT DEBUG: Filter returned empty config!");
1953 }
1954
1955 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1956 }
1957
1958
1959 // Updated function to check intents and invoke the callback function
1960 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
1961 global $wpdb;
1962 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1963
1964 // Get the current bot_id
1965 $current_bot_id = $this->get_current_bot_id($session_id);
1966
1967 // Generate the user embedding
1968 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
1969
1970 // Check if embedding generation returned an error
1971 if (is_array($user_embedding) && isset($user_embedding['error'])) {
1972 $error_message = $user_embedding['error'];
1973 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
1974
1975 // FIXED: Send error in appropriate format based on streaming mode
1976 if ($this->is_streaming) {
1977 echo "data: " . json_encode([
1978 'error' => true,
1979 'error_message' => $error_message,
1980 'error_code' => $error_code,
1981 'text' => $error_message,
1982 'message' => $error_message
1983 ]) . "\n\n";
1984 echo "data: [DONE]\n\n";
1985 flush();
1986 } else {
1987 wp_send_json_error([
1988 'error_message' => $error_message,
1989 'error_code' => $error_code
1990 ]);
1991 }
1992 wp_die();
1993 }
1994
1995 // Check if embedding is valid
1996 if (!is_array($user_embedding) || empty($user_embedding)) {
1997 $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1998
1999 // FIXED: Send error in appropriate format based on streaming mode
2000 if ($this->is_streaming) {
2001 echo "data: " . json_encode([
2002 'error' => true,
2003 'error_message' => $error_message,
2004 'error_code' => 'invalid_embedding',
2005 'text' => $error_message,
2006 'message' => $error_message
2007 ]) . "\n\n";
2008 echo "data: [DONE]\n\n";
2009 flush();
2010 } else {
2011 wp_send_json_error([
2012 'error_message' => $error_message,
2013 'error_code' => 'invalid_embedding'
2014 ]);
2015 }
2016 wp_die();
2017 }
2018
2019 // Fetch intents from the database
2020 $table_name = $wpdb->prefix . 'mxchat_intents';
2021 if ($chat_mode === 'agent') {
2022 $query = $wpdb->prepare(
2023 "SELECT * FROM $table_name WHERE callback_function = %s AND (enabled = 1 OR enabled IS NULL)",
2024 'mxchat_handle_switch_to_chatbot_intent'
2025 );
2026 $intents = $wpdb->get_results($query);
2027 } else {
2028 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2029 }
2030
2031 if (empty($intents)) {
2032 return false;
2033 }
2034
2035 $highest_similarity = -INF;
2036 $matched_intent = null;
2037
2038 // Array to store action analysis for testing panel
2039 $action_analysis = [];
2040
2041 foreach ($intents as $intent) {
2042 // Additional check for enabled state
2043 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2044 if (!$is_enabled) {
2045 continue;
2046 }
2047
2048 // Check if this action is enabled for the current bot
2049 if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2050 continue;
2051 }
2052
2053 $intent_embedding_serialized = $intent->embedding_vector;
2054 $intent_embedding = $intent_embedding_serialized
2055 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2056 : null;
2057
2058 if (!is_array($intent_embedding)) {
2059 continue;
2060 }
2061
2062 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2063 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2064
2065 // Store action analysis data for testing panel
2066 $action_analysis[] = [
2067 'intent_label' => $intent->intent_label,
2068 'callback_function' => $intent->callback_function,
2069 'similarity' => round($similarity, 4),
2070 'similarity_percentage' => round($similarity * 100, 2),
2071 'threshold' => $intent_threshold,
2072 'threshold_percentage' => round($intent_threshold * 100, 2),
2073 'above_threshold' => $similarity >= $intent_threshold,
2074 'triggered' => false // Will be updated below if this intent is triggered
2075 ];
2076
2077 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2078 $highest_similarity = $similarity;
2079 $matched_intent = $intent;
2080 }
2081 }
2082
2083 // Mark the triggered action if any
2084 if ($matched_intent) {
2085 foreach ($action_analysis as &$action) {
2086 if ($action['intent_label'] === $matched_intent->intent_label) {
2087 $action['triggered'] = true;
2088 break;
2089 }
2090 }
2091 }
2092
2093 // Sort actions by similarity (highest first) and store for testing panel
2094 usort($action_analysis, function($a, $b) {
2095 return $b['similarity'] <=> $a['similarity'];
2096 });
2097
2098 // Store action analysis for testing panel capture
2099 $this->last_action_analysis = $action_analysis;
2100
2101 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2102 if ($matched_intent) {
2103 // If the callback is a method on this instance (core callback), call it directly
2104 if (method_exists($this, $matched_intent->callback_function)) {
2105 $callback_result = call_user_func(
2106 [$this, $matched_intent->callback_function],
2107 $message,
2108 $user_id,
2109 $session_id,
2110 $matched_intent,
2111 $user_context ?? null
2112 );
2113 } else {
2114 // Otherwise, use apply_filters for add-on callbacks
2115 $callback_result = apply_filters(
2116 $matched_intent->callback_function,
2117 false,
2118 $message,
2119 $user_id,
2120 $session_id,
2121 $matched_intent
2122 );
2123 }
2124
2125 // Handle the callback result properly
2126 if ($callback_result !== false) {
2127 // If callback returned an array with chat_mode, use it directly
2128 if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2129 $this->fallbackResponse = $callback_result;
2130 return $callback_result; // Return the full array
2131 } else {
2132 $this->fallbackResponse = $callback_result;
2133 return true;
2134 }
2135 }
2136 }
2137
2138 return false;
2139 }
2140
2141 /**
2142 * Check if an action is enabled for a specific bot
2143 */
2144 private function is_action_enabled_for_bot($intent, $bot_id) {
2145 // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2146 if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2147 return true;
2148 }
2149
2150 $enabled_bots = json_decode($intent->enabled_bots, true);
2151
2152 // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2153 if (!is_array($enabled_bots) || empty($enabled_bots)) {
2154 return true;
2155 }
2156
2157 // Check if the current bot is in the enabled bots list
2158 return in_array($bot_id, $enabled_bots);
2159 }
2160
2161 // Helper function to clear PDF and Word document related transients
2162 private function clear_pdf_transients($session_id) {
2163 // PDF transients
2164 delete_transient('mxchat_pdf_url_' . $session_id);
2165 delete_transient('mxchat_pdf_embeddings_' . $session_id);
2166 delete_transient('mxchat_include_pdf_in_context_' . $session_id);
2167 delete_transient('mxchat_waiting_for_pdf_url_' . $session_id);
2168
2169 // Word document transients
2170 delete_transient('mxchat_word_url_' . $session_id);
2171 delete_transient('mxchat_word_filename_' . $session_id);
2172 delete_transient('mxchat_word_embeddings_' . $session_id);
2173 delete_transient('mxchat_include_word_in_context_' . $session_id);
2174 delete_transient('mxchat_waiting_for_word_' . $session_id);
2175 }
2176
2177
2178
2179 //verified good
2180 public function mxchat_handle_email_capture($message, $user_id, $session_id) {
2181 // Get the user's original instruction/message
2182 $user_instruction = esc_html($this->options['triggered_phrase_response'] ?? esc_html__("Please provide your email address.", 'mxchat'));
2183
2184 // Set instruction for AI - just pass along what the user wanted to say
2185 $this->current_action_instruction = $user_instruction;
2186
2187 // Set the transient to track email capture flow
2188 set_transient('mxchat_email_capture_' . $user_id, true, 5 * MINUTE_IN_SECONDS);
2189
2190 // Return false to let the AI generate the response
2191 return false;
2192 }
2193
2194 public function mxchat_generate_image($message, $user_id, $session_id) {
2195 //error_log("Starting image generation for message: " . $message);
2196
2197 // Prepare a prompt for DALL-E
2198 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2199
2200 // Use the existing OpenAI API key
2201 $openai_api_key = sanitize_text_field($this->options['api_key']);
2202
2203 // Call DALL-E to generate an image
2204 $image_response = $this->mxchat_generate_dalle_image($prompt, $openai_api_key);
2205
2206 // Check if the response contains an image URL
2207 if (isset($image_response['imageUrl'])) {
2208 $image_url = esc_url_raw($image_response['imageUrl']);
2209
2210 // Construct the HTML with a CSS class instead of inline styles
2211 $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2212 $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2213
2214 // Save the bot message with both text and HTML
2215 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2216 $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2217
2218 // Set the fallback response for the chat handler
2219 $this->fallbackResponse = [
2220 'text' => $response_text,
2221 'html' => $response_html,
2222 'images' => [$image_url]
2223 ];
2224
2225 // For debugging/verification - Use json_encode to verify what's being set
2226 //error_log("Image generation successful - fallbackResponse set: " . json_encode($this->fallbackResponse));
2227
2228 // Return the response directly instead of relying on the property
2229 return $this->fallbackResponse;
2230 } else {
2231 $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2232
2233 // Save the error message
2234 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2235
2236 // Set the fallback response for the chat handler
2237 $this->fallbackResponse = [
2238 'text' => $response_text,
2239 'html' => '',
2240 'images' => []
2241 ];
2242
2243 //error_log("DALL-E image generation error: " . esc_html($image_response['error'] ?? 'Unknown error.'));
2244 //error_log("Error fallbackResponse set: " . json_encode($this->fallbackResponse));
2245
2246 // Return the response directly instead of relying on the property
2247 return $this->fallbackResponse;
2248 }
2249 }
2250 private function mxchat_generate_dalle_image($prompt, $api_key, $model = 'dall-e-3', $timeout = 60) {
2251 $api_url = 'https://api.openai.com/v1/images/generations';
2252 $body = json_encode([
2253 'prompt' => sanitize_text_field($prompt),
2254 'n' => 1,
2255 'size' => '1024x1024',
2256 'model' => sanitize_text_field($model),
2257 ]);
2258
2259 $args = [
2260 'body' => $body,
2261 'headers' => [
2262 'Content-Type' => 'application/json',
2263 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
2264 ],
2265 'method' => 'POST',
2266 'timeout' => absint($timeout),
2267 ];
2268
2269 $response = wp_remote_post($api_url, $args);
2270
2271 if (is_wp_error($response)) {
2272 //error_log("DALL-E request failed: " . $response->get_error_message());
2273 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
2274 }
2275
2276 $response_body = json_decode(wp_remote_retrieve_body($response), true);
2277
2278 if (isset($response_body['data'][0]['url'])) {
2279 return ['imageUrl' => esc_url_raw($response_body['data'][0]['url'])];
2280 } else {
2281 //error_log("DALL-E response error: " . wp_remote_retrieve_body($response));
2282 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
2283 }
2284 }
2285
2286 /**
2287 * Handle web search requests.
2288 *
2289 * Sends the refined search query to the Brave Search API and uses the
2290 * results to generate a conversational response with the AI model.
2291 *
2292 * @since 1.0.0
2293 * @param string $message The user's search query.
2294 * @param string $user_id The user identifier.
2295 * @param string $session_id The current session ID.
2296 * @return array Response array containing text with embedded HTML links
2297 */
2298 public function mxchat_handle_search_request($message, $user_id, $session_id) {
2299 // Step 1: Interpret and refine the search query
2300 $refined_search_query = $this->mxchat_interpret_search_query($message);
2301 if (empty($refined_search_query)) {
2302 return array(
2303 'text' => esc_html__('I apologize, but could you please rephrase your search request?', 'mxchat'),
2304 'html' => ''
2305 );
2306 }
2307
2308 // Retrieve and validate API settings
2309 $options = get_option('mxchat_options');
2310 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2311 $results_count = isset($options['brave_results_count']) ? absint($options['brave_results_count']) : 5;
2312
2313 if (empty($api_key)) {
2314 return array(
2315 'text' => esc_html__('Search functionality is temporarily unavailable. Please try again later.', 'mxchat'),
2316 'html' => ''
2317 );
2318 }
2319
2320 // Build the API request URL
2321 $api_url = add_query_arg(
2322 array(
2323 'q' => rawurlencode($refined_search_query),
2324 'count' => $results_count,
2325 'text_decorations' => 'true',
2326 'rich_data' => 'true',
2327 ),
2328 'https://api.search.brave.com/res/v1/web/search'
2329 );
2330
2331 // Attempt to retrieve cached results first
2332 $transient_key = 'mxchat_search_' . md5($refined_search_query);
2333 $results = get_transient($transient_key);
2334
2335 if (false === $results) {
2336 // SECURITY FIX: Changed to wp_safe_remote_get
2337 $response = wp_safe_remote_get(
2338 $api_url,
2339 array(
2340 'headers' => array(
2341 'Accept' => 'application/json',
2342 'Accept-Encoding' => 'gzip',
2343 'X-Subscription-Token'=> $api_key,
2344 ),
2345 'timeout' => 10,
2346 )
2347 );
2348
2349 if (is_wp_error($response)) {
2350 return array(
2351 'text' => esc_html__('I encountered an error while searching. Please try again.', 'mxchat'),
2352 'html' => ''
2353 );
2354 }
2355
2356 $results = json_decode(wp_remote_retrieve_body($response), true);
2357
2358 if (json_last_error() !== JSON_ERROR_NONE) {
2359 return array(
2360 'text' => esc_html__('I received an invalid response from the search service.', 'mxchat'),
2361 'html' => ''
2362 );
2363 }
2364
2365 // Cache results for one hour
2366 set_transient($transient_key, $results, HOUR_IN_SECONDS);
2367 }
2368
2369 // Process results
2370 if (!empty($results['web']['results']) && is_array($results['web']['results'])) {
2371 // Create a more straightforward summary with HTML links
2372 $search_results_text = '';
2373
2374 // Add a simple intro
2375 $search_results_text .= sprintf(
2376 esc_html__("Here's what I found about '%s':", 'mxchat'),
2377 esc_html($refined_search_query)
2378 );
2379
2380 // Add the top results with HTML links
2381 foreach (array_slice($results['web']['results'], 0, 5) as $result) {
2382 $title = isset($result['title']) ? wp_strip_all_tags($result['title']) : '';
2383 $url = isset($result['url']) ? esc_url($result['url']) : '';
2384 $description = isset($result['description']) ? wp_strip_all_tags($result['description']) : '';
2385
2386 // Add a line break after the intro
2387 $search_results_text .= '<br><br>';
2388
2389 // Add title as a link
2390 $search_results_text .= sprintf(
2391 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a><br>',
2392 $url,
2393 $title
2394 );
2395
2396 // Add a condensed description
2397 $search_results_text .= sprintf("%s", $description);
2398 }
2399
2400 // Save to chat history
2401 $this->mxchat_save_chat_message($session_id, 'bot', $search_results_text);
2402
2403 // Return the formatted text with embedded HTML links
2404 return array(
2405 'text' => $search_results_text,
2406 'html' => ''
2407 );
2408 } else {
2409 return array(
2410 'text' => sprintf(
2411 esc_html__('I searched for "%s" but couldn\'t find any relevant results. Would you like to try different search terms?', 'mxchat'),
2412 esc_html($refined_search_query)
2413 ),
2414 'html' => ''
2415 );
2416 }
2417 }
2418
2419 //very good
2420 /**
2421 * Handle image search requests from the chatbot
2422 *
2423 * @param string $message The user's search query
2424 * @param int $user_id The user's ID
2425 * @param string $session_id The chat session ID
2426 * @return array Response array with text and HTML content
2427 */
2428 public function mxchat_handle_image_search_request($message, $user_id, $session_id) {
2429 // Step 1: Interpret the search query using the user's selected AI model
2430 $refined_search_query = $this->mxchat_interpret_search_query($message);
2431
2432 // If no query was interpreted, return a fallback message
2433 if (empty($refined_search_query)) {
2434 return array(
2435 'text' => __("I'm sorry, I couldn't interpret your search query. Please specify what you'd like to see images of.", 'mxchat'),
2436 'html' => "",
2437 );
2438 }
2439
2440 // Brave API URL
2441 $api_url = 'https://api.search.brave.com/res/v1/images/search';
2442
2443 // Retrieve Brave API settings
2444 $options = get_option('mxchat_options');
2445 $api_key = isset($options['brave_api_key']) ? sanitize_text_field($options['brave_api_key']) : '';
2446
2447 if (empty($api_key)) {
2448 return array(
2449 'text' => __("API key is not configured. Please set it in the Brave Search Settings.", 'mxchat'),
2450 'html' => "",
2451 );
2452 }
2453
2454 $image_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2455 $safe_search = isset($options['brave_safe_search']) ? sanitize_text_field($options['brave_safe_search']) : 'strict';
2456
2457 // Append query parameters based on settings
2458 $api_url = add_query_arg([
2459 'q' => rawurlencode($refined_search_query),
2460 'count' => $image_count,
2461 'safesearch' => $safe_search,
2462 ], $api_url);
2463
2464 // Implement caching
2465 $transient_key = 'mxchat_image_search_' . md5($refined_search_query);
2466 $body = get_transient($transient_key);
2467
2468 if (false === $body) {
2469 $args = [
2470 'headers' => [
2471 'Accept' => 'application/json',
2472 'Accept-Encoding' => 'gzip',
2473 'X-Subscription-Token' => $api_key,
2474 ],
2475 'timeout' => 10,
2476 ];
2477
2478 // SECURITY FIX: Changed to wp_safe_remote_get
2479 $response = wp_safe_remote_get($api_url, $args);
2480
2481 if (is_wp_error($response)) {
2482 return array(
2483 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
2484 'html' => "",
2485 );
2486 }
2487
2488 $body = json_decode(wp_remote_retrieve_body($response), true);
2489 set_transient($transient_key, $body, HOUR_IN_SECONDS);
2490 }
2491
2492 // Process the API response
2493 if (isset($body['results']) && is_array($body['results']) && count($body['results']) > 0) {
2494 $html_output = '<div class="mxchat-image-gallery">';
2495
2496 // Get the configured image count (1-6)
2497 $display_count = isset($options['brave_image_count']) ? intval($options['brave_image_count']) : 4;
2498 $display_count = min($display_count, count($body['results'])); // Make sure we don't exceed available images
2499
2500 // Use only the requested number of images
2501 for ($i = 0; $i < $display_count; $i++) {
2502 $image = $body['results'][$i];
2503 $image_url = isset($image['url']) ? esc_url($image['url']) : '';
2504 $thumbnail_url = isset($image['thumbnail']['src']) ? esc_url($image['thumbnail']['src']) : '';
2505 $title = isset($image['title']) ? esc_html($image['title']) : esc_html__('Image', 'mxchat');
2506
2507 if ($image_url && $thumbnail_url) {
2508 $html_output .= '<div class="mxchat-image-item">';
2509 $html_output .= '<strong class="mxchat-image-title">' . $title . '</strong>';
2510 $html_output .= '<a href="' . $image_url . '" target="_blank" rel="noopener noreferrer" class="mxchat-image-link">';
2511 $html_output .= '<img src="' . $thumbnail_url . '" alt="' . $title . '" class="mxchat-image-thumbnail">';
2512 $html_output .= '</a></div>';
2513 }
2514 }
2515
2516 $html_output .= '</div>';
2517
2518 // Create response text
2519 $response_text = sprintf(__("Here are some images of %s:", 'mxchat'), $refined_search_query);
2520
2521 // Save both response text and HTML to chat history
2522 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2523 $this->mxchat_save_chat_message($session_id, 'bot', $html_output);
2524
2525 // Return the combined response
2526 return array(
2527 'text' => $response_text,
2528 'html' => $html_output,
2529 );
2530 } else {
2531 $response_text = __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat');
2532
2533 // Save the error message to chat history
2534 $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2535
2536 return array(
2537 'text' => $response_text,
2538 'html' => "",
2539 );
2540 }
2541 }
2542
2543 /**
2544 * Interpret the search query using the user's selected AI model
2545 *
2546 * @param string $user_query The original query from the user
2547 * @return string The refined search query
2548 */
2549 public function mxchat_interpret_search_query($user_query) {
2550 $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');
2551
2552 // Get options and determine the selected model
2553 $options = $this->options ?? get_option('mxchat_options');
2554 $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.1-chat-latest';
2555
2556 // Extract model prefix to determine the provider
2557 $model_parts = explode('-', $selected_model);
2558 $provider = strtolower($model_parts[0]);
2559
2560 // Determine which API key to use based on the provider
2561 switch ($provider) {
2562 case 'gemini':
2563 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
2564 if (empty($api_key)) {
2565 return sanitize_text_field($user_query); // Default to original query if API key missing
2566 }
2567 return $this->interpret_query_with_gemini($user_query, $system_prompt, $api_key, $selected_model);
2568
2569 case 'claude':
2570 $api_key = isset($options['claude_api_key']) ? sanitize_text_field($options['claude_api_key']) : '';
2571 if (empty($api_key)) {
2572 return sanitize_text_field($user_query);
2573 }
2574 return $this->interpret_query_with_claude($user_query, $system_prompt, $api_key, $selected_model);
2575
2576 case 'grok':
2577 $api_key = isset($options['xai_api_key']) ? sanitize_text_field($options['xai_api_key']) : '';
2578 if (empty($api_key)) {
2579 return sanitize_text_field($user_query);
2580 }
2581 return $this->interpret_query_with_xai($user_query, $system_prompt, $api_key, $selected_model);
2582
2583 case 'deepseek':
2584 $api_key = isset($options['deepseek_api_key']) ? sanitize_text_field($options['deepseek_api_key']) : '';
2585 if (empty($api_key)) {
2586 return sanitize_text_field($user_query);
2587 }
2588 return $this->interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $selected_model);
2589
2590 case 'gpt':
2591 default:
2592 // Default to OpenAI for custom models or unrecognized prefixes
2593 $api_key = isset($options['api_key']) ? sanitize_text_field($options['api_key']) : '';
2594 if (empty($api_key)) {
2595 return sanitize_text_field($user_query);
2596 }
2597 return $this->interpret_query_with_openai($user_query, $system_prompt, $api_key, $selected_model);
2598 }
2599 }
2600
2601 /**
2602 * Interpret query using OpenAI models
2603 */
2604 private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.1-chat-latest') {
2605 $url = 'https://api.openai.com/v1/chat/completions';
2606 $args = [
2607 'headers' => [
2608 'Authorization' => 'Bearer ' . $api_key,
2609 'Content-Type' => 'application/json',
2610 ],
2611 'body' => wp_json_encode([
2612 'model' => $model,
2613 'messages' => [
2614 ['role' => 'system', 'content' => $system_prompt],
2615 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2616 ],
2617 'temperature' => 0.2,
2618 'max_tokens' => 20,
2619 ]),
2620 'method' => 'POST',
2621 'timeout' => 15,
2622 ];
2623
2624 $response = wp_remote_post($url, $args);
2625 if (is_wp_error($response)) {
2626 return sanitize_text_field($user_query);
2627 }
2628
2629 $body = json_decode(wp_remote_retrieve_body($response), true);
2630 return isset($body['choices'][0]['message']['content'])
2631 ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
2632 : sanitize_text_field($user_query);
2633 }
2634
2635 /**
2636 * Interpret query using Claude models
2637 */
2638 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
2639 $url = 'https://api.anthropic.com/v1/messages';
2640
2641 $args = [
2642 'headers' => [
2643 'Content-Type' => 'application/json',
2644 'x-api-key' => $api_key,
2645 'anthropic-version' => '2023-06-01',
2646 ],
2647 'body' => wp_json_encode([
2648 'model' => $model,
2649 'system' => $system_prompt,
2650 'messages' => [
2651 ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2652 ],
2653 'max_tokens' => 20,
2654 'temperature' => 0.2,
2655 ]),
2656 'method' => 'POST',
2657 'timeout' => 15,
2658 ];
2659
2660 $response = wp_remote_post($url, $args);
2661 if (is_wp_error($response)) {
2662 return sanitize_text_field($user_query);
2663 }
2664
2665 $body = json_decode(wp_remote_retrieve_body($response), true);
2666 if (!empty($body['content'][0]['text'])) {
2667 return sanitize_text_field(trim($body['content'][0]['text']));
2668 }
2669
2670 return sanitize_text_field($user_query);
2671 }
2672
2673 /**
2674 * Interpret query using Gemini models
2675 */
2676 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
2677 // Use v1beta for preview models, v1 for stable models
2678 $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
2679
2680 $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2681
2682 $args = [
2683 'headers' => [
2684 'Content-Type' => 'application/json',
2685 ],
2686 'body' => wp_json_encode([
2687 'contents' => [
2688 [
2689 'role' => 'user',
2690 'parts' => [
2691 ['text' => $system_prompt . "\n\nQuery: " . sanitize_text_field($user_query)]
2692 ]
2693 ]
2694 ],
2695 'generationConfig' => [
2696 'temperature' => 0.2,
2697 'maxOutputTokens' => 20,
2698 ],
2699 ]),
2700 'method' => 'POST',
2701 'timeout' => 15,
2702 ];
2703
2704 $response = wp_remote_post($url, $args);
2705 if (is_wp_error($response)) {
2706 return sanitize_text_field($user_query);
2707 }
2708
2709 $body = json_decode(wp_remote_retrieve_body($response), true);
2710 if (!empty($body['candidates'][0]['content']['parts'][0]['text'])) {
2711 return sanitize_text_field(trim($body['candidates'][0]['content']['parts'][0]['text']));
2712 }
2713
2714 return sanitize_text_field($user_query);
2715 }
2716
2717 /**
2718 * Interpret query using X.AI (Grok) models
2719 */
2720 private function interpret_query_with_xai($user_query, $system_prompt, $api_key, $model) {
2721 $url = 'https://api.xai.com/v1/chat/completions';
2722
2723 $args = [
2724 'headers' => [
2725 'Content-Type' => 'application/json',
2726 'Authorization' => 'Bearer ' . $api_key,
2727 ],
2728 'body' => wp_json_encode([
2729 'model' => $model,
2730 'messages' => [
2731 ['role' => 'system', 'content' => $system_prompt],
2732 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2733 ],
2734 'temperature' => 0.2,
2735 'max_tokens' => 20,
2736 ]),
2737 'method' => 'POST',
2738 'timeout' => 15,
2739 ];
2740
2741 $response = wp_remote_post($url, $args);
2742 if (is_wp_error($response)) {
2743 return sanitize_text_field($user_query);
2744 }
2745
2746 $body = json_decode(wp_remote_retrieve_body($response), true);
2747 if (isset($body['choices'][0]['message']['content'])) {
2748 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2749 }
2750
2751 return sanitize_text_field($user_query);
2752 }
2753
2754 /**
2755 * Interpret query using DeepSeek models
2756 */
2757 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
2758 $url = 'https://api.deepseek.com/v1/chat/completions';
2759
2760 $args = [
2761 'headers' => [
2762 'Content-Type' => 'application/json',
2763 'Authorization' => 'Bearer ' . $api_key,
2764 ],
2765 'body' => wp_json_encode([
2766 'model' => $model,
2767 'messages' => [
2768 ['role' => 'system', 'content' => $system_prompt],
2769 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2770 ],
2771 'temperature' => 0.2,
2772 'max_tokens' => 20,
2773 ]),
2774 'method' => 'POST',
2775 'timeout' => 15,
2776 ];
2777
2778 $response = wp_remote_post($url, $args);
2779 if (is_wp_error($response)) {
2780 return sanitize_text_field($user_query);
2781 }
2782
2783 $body = json_decode(wp_remote_retrieve_body($response), true);
2784 if (isset($body['choices'][0]['message']['content'])) {
2785 return sanitize_text_field(trim($body['choices'][0]['message']['content']));
2786 }
2787
2788 return sanitize_text_field($user_query);
2789 }
2790
2791 //very good
2792 private function add_email_to_loops($email) {
2793 // Sanitize the email
2794 $email = sanitize_email($email);
2795
2796 // Retrieve and sanitize options
2797 $api_key = isset($this->options['loops_api_key']) ? sanitize_text_field($this->options['loops_api_key']) : '';
2798 $mailing_list_id = isset($this->options['loops_mailing_list']) ? sanitize_text_field($this->options['loops_mailing_list']) : '';
2799
2800 // Check for missing API key or mailing list ID
2801 if (empty($api_key) || empty($mailing_list_id)) {
2802 //error_log(esc_html__('Loops API key or mailing list ID is missing.', 'mxchat'));
2803 return;
2804 }
2805
2806 $data = array(
2807 'email' => $email,
2808 'subscribed' => true,
2809 'source' => __('MxChat AI Chatbot', 'mxchat'),
2810 'mailingLists' => array($mailing_list_id => true),
2811 );
2812
2813 $url = 'https://app.loops.so/api/v1/contacts/create';
2814 $args = array(
2815 'body' => wp_json_encode($data),
2816 'headers' => array(
2817 'Authorization' => 'Bearer ' . $api_key,
2818 'Content-Type' => 'application/json',
2819 ),
2820 'method' => 'POST',
2821 'timeout' => 45,
2822 );
2823
2824 $response = wp_remote_post($url, $args);
2825
2826 // Handle errors in the API request
2827 if (is_wp_error($response)) {
2828 //error_log(esc_html__('Error adding email to Loops: ', 'mxchat') . $response->get_error_message());
2829 return;
2830 }
2831
2832 // Check for non-200 HTTP responses
2833 $response_code = wp_remote_retrieve_response_code($response);
2834 if ($response_code != 200) {
2835 $response_body = wp_remote_retrieve_body($response);
2836 //error_log(esc_html__('Loops API responded with code ', 'mxchat') . $response_code . ': ' . $response_body);
2837 }
2838 }
2839
2840 public function mxchat_handle_pdf_discussion($message, $user_id, $session_id) {
2841 // Get the maximum number of pages allowed from admin settings
2842 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
2843
2844 // Retrieve options for dynamic texts
2845 $trigger_text = $this->options['pdf_intent_trigger_text'] ?? __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
2846 $success_text = $this->options['pdf_intent_success_text'] ?? __("I've processed the PDF. What questions do you have about it?", 'mxchat');
2847 $error_text = $this->options['pdf_intent_error_text'] ?? __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2848
2849 // Check for explicit request for new PDF
2850 $new_pdf_requested = stripos($message, 'new') !== false ||
2851 stripos($message, 'another') !== false ||
2852 stripos($message, 'different') !== false;
2853
2854 // If user mentions adding/reading a PDF, set waiting flag
2855 if (stripos($message, 'pdf') !== false ||
2856 stripos($message, 'document') !== false ||
2857 stripos($message, 'read') !== false) {
2858 set_transient('mxchat_waiting_for_pdf_url_' . $session_id, true, HOUR_IN_SECONDS);
2859 $this->fallbackResponse['text'] = $trigger_text;
2860 return;
2861 }
2862
2863 // If we're waiting for a URL or user requested new PDF
2864 if ($new_pdf_requested || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
2865 if (preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
2866 // Process URL... (rest of your existing URL processing code)
2867 } else {
2868 $this->fallbackResponse['text'] = $trigger_text;
2869 }
2870 return;
2871 }
2872
2873 // Default to proceeding with conversation if no specific PDF action is needed
2874 $this->fallbackResponse['text'] = '';
2875 }
2876
2877
2878 /**
2879 * Enhanced fetch_and_split_pdf_pages with SSRF protection
2880 */
2881 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
2882 // CLEAR DEBUG LOGGING
2883 //error_log("=== MXCHAT PDF PROCESSING START ===");
2884 //error_log("PDF Source: " . $pdf_source);
2885 //error_log("Max Pages: " . $max_pages);
2886 //error_log("Session ID: " . ($this->session_id ?? 'not set'));
2887
2888 // Check if Advanced Claude Toolbar is available and enabled
2889 $claude_available = function_exists('mxchatACT_is_advanced_claude_enabled');
2890 $claude_enabled = $claude_available ? mxchatACT_is_advanced_claude_enabled() : false;
2891
2892 //error_log("Claude Function Available: " . ($claude_available ? 'YES' : 'NO'));
2893 //error_log("Claude Enabled: " . ($claude_enabled ? 'YES' : 'NO'));
2894
2895 if ($claude_available && $claude_enabled) {
2896 //error_log("🚀 ATTEMPTING CLAUDE PROCESSING...");
2897
2898 // Attempt Claude processing first
2899 $claude_result = apply_filters('mxchat_process_pdf_advanced', false, $pdf_source, $max_pages, $this->session_id);
2900
2901 if ($claude_result !== false && is_array($claude_result) && !empty($claude_result)) {
2902 //error_log("✅ CLAUDE PROCESSING SUCCESSFUL!");
2903 //error_log("Claude returned " . count($claude_result) . " processed pages");
2904
2905 // Log first page details for verification
2906 if (isset($claude_result[0])) {
2907 $first_page = $claude_result[0];
2908 //error_log("First page enhanced: " . (isset($first_page['enhanced']) && $first_page['enhanced'] ? 'YES' : 'NO'));
2909 //error_log("Processing method: " . ($first_page['processing_method'] ?? 'not set'));
2910 //error_log("First page text preview: " . substr($first_page['text'] ?? '', 0, 100) . "...");
2911 }
2912
2913 //error_log("=== MXCHAT PDF PROCESSING END (CLAUDE) ===");
2914 return $claude_result;
2915 } else {
2916 //error_log("❌ CLAUDE PROCESSING FAILED or returned invalid result");
2917 //error_log("Claude result type: " . gettype($claude_result));
2918 if (is_array($claude_result)) {
2919 //error_log("Claude result count: " . count($claude_result));
2920 }
2921 }
2922 }
2923
2924 // Fallback to basic processing
2925 //error_log("🔄 FALLING BACK TO BASIC PDF PROCESSING...");
2926
2927 $upload_dir = wp_upload_dir();
2928 $temp_file = null;
2929
2930 try {
2931 // Your existing basic processing code here...
2932 // (I'll include the key parts with debug logging)
2933
2934 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
2935 //error_log("Downloading PDF from URL...");
2936
2937 // SECURITY FIX: Validate URL before processing
2938 if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
2939 //error_log("❌ SECURITY: Blocked unsafe PDF URL");
2940 return false;
2941 }
2942
2943 $temp_file = wp_tempnam($pdf_source);
2944
2945 // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
2946 $response = wp_safe_remote_get($pdf_source, [
2947 'timeout' => 60,
2948 'headers' => ['User-Agent' => 'MxChat PDF Processor']
2949 ]);
2950
2951 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2952 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
2953 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
2954 return false;
2955 }
2956
2957 file_put_contents($temp_file, wp_remote_retrieve_body($response));
2958 //error_log("✅ PDF downloaded successfully");
2959 } else {
2960 $temp_file = $pdf_source;
2961 //error_log("Using local PDF file: " . $temp_file);
2962 }
2963
2964 // Parse PDF
2965 //error_log("Parsing PDF with basic parser...");
2966 $parser = new \Smalot\PdfParser\Parser();
2967 $pdf = $parser->parseFile($temp_file);
2968 $pages = $pdf->getPages();
2969
2970 //error_log("PDF contains " . count($pages) . " pages");
2971
2972 if (count($pages) > $max_pages) {
2973 //error_log("❌ BASIC PROCESSING: Too many pages (" . count($pages) . " > " . $max_pages . ")");
2974 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file) {
2975 unlink($temp_file);
2976 }
2977 return 'too_many_pages';
2978 }
2979
2980 $embeddings = [];
2981 $processed_pages = 0;
2982
2983 foreach ($pages as $page_number => $page) {
2984 $text = $page->getText();
2985
2986 if (empty(trim($text))) {
2987 //error_log("Skipping empty page: " . ($page_number + 1));
2988 continue;
2989 }
2990
2991 $text = $this->mxchat_clean_text($text);
2992
2993 $embedding = $this->mxchat_generate_embedding(
2994 __("Page ", 'mxchat') . ($page_number + 1) . ": " . $text,
2995 $this->options['api_key']
2996 );
2997
2998 if ($embedding) {
2999 $embeddings[] = [
3000 'page_number' => $page_number + 1,
3001 'embedding' => $embedding,
3002 'text' => $text,
3003 'enhanced' => false, // CLEARLY MARK AS BASIC
3004 'processing_method' => 'basic_pdf_parser'
3005 ];
3006 $processed_pages++;
3007 }
3008 }
3009
3010 //error_log("✅ BASIC PROCESSING COMPLETE: " . $processed_pages . " pages processed");
3011
3012 // Cleanup
3013 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3014 unlink($temp_file);
3015 }
3016
3017 //error_log("=== MXCHAT PDF PROCESSING END (BASIC) ===");
3018 return $embeddings;
3019
3020 } catch (\Exception $e) {
3021 //error_log("❌ BASIC PROCESSING ERROR: " . $e->getMessage());
3022 if (filter_var($pdf_source, FILTER_VALIDATE_URL) && $temp_file && file_exists($temp_file)) {
3023 unlink($temp_file);
3024 }
3025 //error_log("=== MXCHAT PDF PROCESSING END (ERROR) ===");
3026 return false;
3027 }
3028 }
3029
3030
3031 /**
3032 * Validate PDF URL for security
3033 * Prevents SSRF attacks by blocking dangerous URLs
3034 */
3035
3036 private function mxchat_is_safe_pdf_url($url) {
3037 // Use WordPress core function for comprehensive validation
3038 // This blocks localhost, private IPs, and reserved IP ranges
3039 $validated_url = wp_http_validate_url($url);
3040
3041 if ($validated_url === false) {
3042 return false;
3043 }
3044
3045 // Additional check: only allow HTTP/HTTPS schemes
3046 $parsed = parse_url($url);
3047 if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
3048 return false;
3049 }
3050
3051 return true;
3052 }
3053
3054
3055 private function mxchat_clean_text($text) {
3056 // Remove excessive whitespace
3057 $text = preg_replace('/\s+/', ' ', $text);
3058
3059 // Remove control characters except newlines and tabs
3060 $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
3061
3062 // Normalize line endings
3063 $text = str_replace(["\r\n", "\r"], "\n", $text);
3064
3065 // Trim whitespace
3066 $text = trim($text);
3067
3068 return $text;
3069 }
3070
3071 private function find_relevant_pdf_pages($query_embedding, $embeddings) {
3072 //error_log(esc_html__("find_relevant_pdf_pages called.", 'mxchat'));
3073
3074 $most_relevant = null;
3075 $highest_similarity = -INF;
3076
3077 foreach ($embeddings as $page_data) {
3078 $similarity = $this->mxchat_calculate_cosine_similarity($query_embedding, $page_data['embedding']);
3079
3080 if ($similarity > $highest_similarity) {
3081 $highest_similarity = $similarity;
3082 $most_relevant = $page_data['page_number'];
3083 }
3084 }
3085
3086 if (!is_null($most_relevant)) {
3087 $page_numbers = range(max(1, $most_relevant - 1), min(count($embeddings), $most_relevant + 1));
3088 return array_filter($embeddings, function ($page) use ($page_numbers) {
3089 return in_array($page['page_number'], $page_numbers);
3090 });
3091 }
3092
3093 return [];
3094 }
3095
3096
3097 public function handle_pdf_upload() {
3098 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3099
3100 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
3101 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
3102 return;
3103 }
3104
3105 // SECURITY FIX: Check if PDF uploads are enabled in settings
3106 $options = get_option('mxchat_options', array());
3107 $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
3108
3109 if ($show_pdf_button !== 'on') {
3110 wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
3111 return;
3112 }
3113
3114 $file = $_FILES['pdf_file'];
3115 $session_id = sanitize_text_field($_POST['session_id']);
3116 $original_filename = sanitize_text_field($file['name']);
3117
3118 // SECURITY FIX: Verify session ownership before allowing upload
3119 $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
3120 $session_owner = get_option("mxchat_session_owner_{$session_id}");
3121
3122 if ($session_owner && $session_owner !== $current_user_identifier) {
3123 wp_send_json_error(esc_html__('Unauthorized access.', 'mxchat'));
3124 return;
3125 }
3126
3127 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
3128 if ($file_type['type'] !== 'application/pdf') {
3129 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
3130 return;
3131 }
3132
3133 $upload_dir = wp_upload_dir();
3134
3135 // SECURITY FIX: Generate random filename without exposing session_id
3136 $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
3137 $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
3138 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
3139
3140 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
3141 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
3142 return;
3143 }
3144
3145 $this->clear_pdf_transients($session_id);
3146
3147 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
3148 $embeddings = $this->fetch_and_split_pdf_pages($pdf_path, $max_pages);
3149
3150 if ($embeddings === 'too_many_pages') {
3151 unlink($pdf_path);
3152 $error_message = sprintf(
3153 $this->options['pdf_intent_error_text'] ??
3154 esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
3155 $max_pages
3156 );
3157 wp_send_json_error($error_message);
3158 return;
3159 }
3160
3161 if ($embeddings === false || empty($embeddings)) {
3162 unlink($pdf_path);
3163 $error_message = $this->options['pdf_intent_error_text'] ??
3164 esc_html__('The uploaded PDF appears to be empty or contains unsupported content.', 'mxchat');
3165 wp_send_json_error($error_message);
3166 return;
3167 }
3168
3169 if (!empty($embeddings)) {
3170 // Store the mapping between session and the random filename
3171 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
3172 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
3173 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
3174 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
3175
3176 $success_message = $this->options['pdf_intent_success_text'] ??
3177 esc_html__("I've processed the PDF. What questions do you have about it?", 'mxchat');
3178
3179 wp_send_json_success([
3180 'message' => $success_message,
3181 'filename' => $original_filename
3182 ]);
3183 return;
3184 }
3185
3186 unlink($pdf_path);
3187 $error_message = $this->options['pdf_intent_error_text'] ??
3188 esc_html__('Sorry, I couldn\'t process the PDF. Please ensure it\'s a valid file.', 'mxchat');
3189 wp_send_json_error($error_message);
3190 return;
3191 }
3192 public function handle_pdf_remove() {
3193 check_ajax_referer('mxchat_chat_nonce', 'nonce');
3194
3195 if (empty($_POST['session_id'])) {
3196 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
3197 wp_die();
3198 }
3199
3200 $session_id = sanitize_text_field($_POST['session_id']);
3201 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
3202
3203 if ($pdf_path && file_exists($pdf_path)) {
3204 unlink($pdf_path);
3205 }
3206
3207 $this->clear_pdf_transients($session_id);
3208
3209 wp_send_json_success([
3210 'message' => esc_html__('PDF removed successfully.', 'mxchat')
3211 ]);
3212 wp_die();
3213 }
3214
3215
3216 function mxchat_fetch_new_messages() {
3217 $session_id = sanitize_text_field($_POST['session_id']);
3218 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
3219 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
3220 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
3221
3222 if (empty($session_id)) {
3223 //error_log(esc_html__('Fetch new messages error: Session ID missing.', 'mxchat'));
3224 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
3225 wp_die();
3226 }
3227
3228 $history = get_option("mxchat_history_{$session_id}", []);
3229
3230 error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
3231 error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
3232 error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
3233 error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
3234
3235 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
3236 error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
3237
3238 // If persistence is enabled, show all new messages
3239 if ($persistence_enabled) {
3240 $has_id = !empty($message['id']);
3241 $is_agent = $message['role'] === 'agent';
3242
3243 // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
3244 if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
3245 $is_newer = true;
3246 } else {
3247 $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
3248 }
3249
3250 error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
3251
3252 return $has_id && $is_newer && $is_agent;
3253 }
3254
3255 // If persistence is disabled, only show messages after initial timestamp
3256 return !empty($message['id']) &&
3257 $message['role'] === 'agent' &&
3258 $message['timestamp'] > $initial_timestamp;
3259 });
3260
3261 error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
3262
3263 wp_send_json_success([
3264 'new_messages' => array_values($new_messages)
3265 ]);
3266 wp_die();
3267 }
3268 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
3269 // First check if live agents are available
3270 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
3271 if ($live_agent_available !== 'on') {
3272 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3273 $this->fallbackResponse = [
3274 'text' => $away_message,
3275 'html' => '',
3276 'images' => [],
3277 'chat_mode' => 'ai'
3278 ];
3279 wp_send_json([
3280 'text' => $away_message,
3281 'html' => '',
3282 'chat_mode' => 'ai',
3283 'session_id' => $session_id
3284 ]);
3285 wp_die();
3286 }
3287
3288 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3289
3290 if (empty($slack_bot_token)) {
3291 return false;
3292 }
3293
3294 // Check if channel already exists for this session
3295 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3296
3297 if (empty($channel_id)) {
3298 // Create new channel with session ID as name
3299 $channel_name = $this->generate_channel_name($session_id);
3300
3301 //error_log("Attempting to create channel: $channel_name");
3302
3303 $response = wp_remote_post('https://slack.com/api/conversations.create', [
3304 'headers' => [
3305 'Content-Type' => 'application/json',
3306 'Authorization' => 'Bearer ' . $slack_bot_token
3307 ],
3308 'body' => json_encode([
3309 'name' => $channel_name,
3310 'is_private' => false // Public channel - anyone in workspace can join
3311 ])
3312 ]);
3313
3314 if (!is_wp_error($response)) {
3315 $response_body = wp_remote_retrieve_body($response);
3316 $response_data = json_decode($response_body, true);
3317
3318 //error_log("Channel creation response: " . $response_body);
3319
3320 if (isset($response_data['ok']) && $response_data['ok']) {
3321 $channel_id = $response_data['channel']['id'];
3322 $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
3323 //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
3324 update_option("mxchat_channel_{$session_id}", $channel_id);
3325
3326 // Auto-invite agents to the channel
3327 $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
3328
3329 if (!empty($agent_user_ids)) {
3330 // Parse user IDs (one per line)
3331 $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
3332
3333 foreach ($user_ids as $user_id_to_invite) {
3334 //error_log("Inviting user to channel: $user_id_to_invite");
3335
3336 $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
3337 'headers' => [
3338 'Content-Type' => 'application/json',
3339 'Authorization' => 'Bearer ' . $slack_bot_token
3340 ],
3341 'body' => json_encode([
3342 'channel' => $channel_id,
3343 'users' => $user_id_to_invite
3344 ])
3345 ]);
3346
3347 if (!is_wp_error($invite_response)) {
3348 $invite_body = wp_remote_retrieve_body($invite_response);
3349 $invite_data = json_decode($invite_body, true);
3350 //error_log("Invite response for $user_id_to_invite: " . $invite_body);
3351
3352 if (isset($invite_data['ok']) && $invite_data['ok']) {
3353 //error_log("Successfully invited user $user_id_to_invite to channel");
3354 } else {
3355 //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
3356 }
3357 } else {
3358 //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
3359 }
3360 }
3361 } else {
3362 //error_log("No agent user IDs configured for auto-invite");
3363 }
3364 } else {
3365 //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
3366 }
3367 } else {
3368 //error_log("WP Error creating channel: " . $response->get_error_message());
3369 }
3370
3371 if (empty($channel_id)) {
3372 return false; // Failed to create channel
3373 }
3374 }
3375
3376 // Get recent chat history
3377 $history = get_option("mxchat_history_{$session_id}", []);
3378 $recent_history = array_slice($history, -5);
3379
3380 // Format conversation context
3381 $conversation_context = "";
3382 if (!empty($recent_history)) {
3383 $conversation_context = "*Recent Conversation:*\n";
3384 foreach ($recent_history as $hist_message) {
3385 $role_display = $hist_message['role'] === 'user' ? 'User' : 'AI';
3386 $conversation_context .= ">{$role_display}: {$hist_message['content']}\n";
3387 }
3388 $conversation_context .= "\n";
3389 }
3390
3391 update_option("mxchat_mode_{$session_id}", 'agent');
3392
3393 // Send message to channel
3394 $channel_message = "🔔 *New Live Agent Request*\n\n";
3395 $channel_message .= "*Session ID:* `{$session_id}`\n";
3396 $channel_message .= "*User ID:* `{$user_id}`\n\n";
3397
3398 if (!empty($conversation_context)) {
3399 $channel_message .= $conversation_context;
3400 }
3401
3402 $channel_message .= "*Current Message:*\n{$message}\n\n";
3403 $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
3404
3405 wp_remote_post('https://slack.com/api/chat.postMessage', [
3406 'headers' => [
3407 'Content-Type' => 'application/json',
3408 'Authorization' => 'Bearer ' . $slack_bot_token
3409 ],
3410 'body' => json_encode([
3411 'channel' => $channel_id,
3412 'text' => $channel_message,
3413 'mrkdwn' => true
3414 ])
3415 ]);
3416
3417 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
3418 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3419
3420 $this->fallbackResponse = [
3421 'text' => $success_message,
3422 'html' => '',
3423 'images' => [],
3424 'chat_mode' => 'agent'
3425 ];
3426
3427 wp_send_json([
3428 'success' => true,
3429 'text' => $success_message,
3430 'html' => '',
3431 'chat_mode' => 'agent',
3432 'session_id' => $session_id,
3433 'fallbackResponse' => $this->fallbackResponse
3434 ]);
3435 wp_die();
3436 }
3437
3438 private function generate_channel_name($session_id) {
3439 $email = null;
3440 $name = null;
3441
3442 // 1. First priority: Check if user is logged in and get their info
3443 if (is_user_logged_in()) {
3444 $current_user = wp_get_current_user();
3445 if (!empty($current_user->user_email)) {
3446 $email = $current_user->user_email;
3447 //error_log("[DEBUG] Using logged-in user email for channel: {$email}");
3448 }
3449 if (!empty($current_user->display_name)) {
3450 $name = $current_user->display_name;
3451 //error_log("[DEBUG] Using logged-in user name for channel: {$name}");
3452 }
3453 }
3454
3455 // 2. Second priority: Check for saved email/name from "require email to chat" option
3456 if (empty($email)) {
3457 $email_option_key = "mxchat_email_{$session_id}";
3458 $saved_email = get_option($email_option_key);
3459 if (!empty($saved_email)) {
3460 $email = $saved_email;
3461 //error_log("[DEBUG] Using saved email from session for channel: {$email}");
3462 }
3463 }
3464
3465 if (empty($name)) {
3466 $name_option_key = "mxchat_name_{$session_id}";
3467 $saved_name = get_option($name_option_key);
3468 if (!empty($saved_name)) {
3469 $name = $saved_name;
3470 //error_log("[DEBUG] Using saved name from session for channel: {$name}");
3471 }
3472 }
3473
3474 // 3. Third priority: Check existing chat transcript for email/name
3475 if (empty($email) || empty($name)) {
3476 global $wpdb;
3477 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3478 $existing_data = $wpdb->get_row($wpdb->prepare(
3479 "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",
3480 $session_id
3481 ));
3482
3483 if ($existing_data) {
3484 if (empty($email) && !empty($existing_data->user_email)) {
3485 $email = $existing_data->user_email;
3486 //error_log("[DEBUG] Using email from chat transcript for channel: {$email}");
3487 }
3488 if (empty($name) && !empty($existing_data->user_name)) {
3489 $name = $existing_data->user_name;
3490 //error_log("[DEBUG] Using name from chat transcript for channel: {$name}");
3491 }
3492 }
3493 }
3494
3495 // 4. Generate channel name based on priority: Name > Email > Session ID
3496 $channel_name = '';
3497
3498 if (!empty($name)) {
3499 // Convert name to valid Slack channel name
3500 $base_name = strtolower(trim($name));
3501 // Replace spaces and invalid characters
3502 $base_name = preg_replace('/[^a-z0-9\s]/', '', $base_name);
3503 $base_name = preg_replace('/\s+/', '-', $base_name);
3504 $base_name = trim($base_name, '-');
3505
3506 // Get last 4 characters of session ID for uniqueness
3507 $session_suffix = substr($session_id, -4);
3508 $channel_name = 'chat-' . $base_name . '-' . strtolower($session_suffix);
3509
3510 // Slack channel names have a 21 character limit
3511 if (strlen($channel_name) > 21) {
3512 // Calculate available space for name (21 - 'chat-' - '-' - session_suffix)
3513 $available_space = 21 - 5 - 1 - strlen($session_suffix); // 'chat-' = 5, '-' = 1
3514 $truncated_name = substr($base_name, 0, $available_space);
3515 $truncated_name = rtrim($truncated_name, '-'); // Remove trailing hyphen
3516 $channel_name = 'chat-' . $truncated_name . '-' . strtolower($session_suffix);
3517 }
3518
3519 //error_log("[DEBUG] Using name for channel: {$channel_name} (from name: {$name})");
3520
3521 } elseif (!empty($email)) {
3522 // Convert email to valid Slack channel name (your existing logic)
3523 $channel_name = 'chat-' . strtolower(str_replace(['@', '.', '+', '_'], ['-at-', '-', '-plus-', '-'], $email));
3524 // Remove any remaining invalid characters
3525 $channel_name = preg_replace('/[^a-z0-9\-]/', '', $channel_name);
3526 // Ensure it doesn't end with a hyphen
3527 $channel_name = rtrim($channel_name, '-');
3528 // Slack channel names have a 21 character limit, so truncate if needed
3529 if (strlen($channel_name) > 21) {
3530 $channel_name = substr($channel_name, 0, 21);
3531 $channel_name = rtrim($channel_name, '-'); // Remove trailing hyphen if truncation created one
3532 }
3533
3534 //error_log("[DEBUG] Using email for channel: {$channel_name} (from email: {$email})");
3535
3536 } else {
3537 // Fallback to session ID if no name or email found
3538 $channel_name = 'chat-' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $session_id));
3539 //error_log("[DEBUG] No name or email found, using session ID for channel: {$channel_name}");
3540 }
3541
3542 // Final validation - ensure channel name meets Slack requirements
3543 if (strlen($channel_name) > 21) {
3544 $channel_name = substr($channel_name, 0, 21);
3545 $channel_name = rtrim($channel_name, '-');
3546 }
3547
3548 //error_log("[DEBUG] Generated channel name: {$channel_name}");
3549 return $channel_name;
3550 }
3551
3552 /**
3553 * Telegram Live Agent Handover
3554 * Creates a forum topic in the Telegram group and notifies agents
3555 */
3556 public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
3557 // Check if Telegram agents are available
3558 $telegram_available = $this->options['telegram_status'] ?? 'off';
3559 if ($telegram_available !== 'on') {
3560 $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
3561 $this->fallbackResponse = [
3562 'text' => $away_message,
3563 'html' => '',
3564 'images' => [],
3565 'chat_mode' => 'ai'
3566 ];
3567 wp_send_json([
3568 'text' => $away_message,
3569 'html' => '',
3570 'chat_mode' => 'ai',
3571 'session_id' => $session_id
3572 ]);
3573 wp_die();
3574 }
3575
3576 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3577 $telegram_group_id = $this->options['telegram_group_id'] ?? '';
3578
3579 if (empty($telegram_bot_token) || empty($telegram_group_id)) {
3580 return false;
3581 }
3582
3583 // Check if topic already exists for this session
3584 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3585
3586 if (empty($topic_id)) {
3587 // Generate topic name
3588 $topic_name = $this->generate_telegram_topic_name($session_id);
3589
3590 // Random icon color (Telegram forum topic colors)
3591 $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
3592 $icon_color = $icon_colors[array_rand($icon_colors)];
3593
3594 // Create forum topic
3595 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
3596 'headers' => ['Content-Type' => 'application/json'],
3597 'body' => json_encode([
3598 'chat_id' => $telegram_group_id,
3599 'name' => $topic_name,
3600 'icon_color' => $icon_color
3601 ])
3602 ]);
3603
3604 if (!is_wp_error($response)) {
3605 $response_body = wp_remote_retrieve_body($response);
3606 $response_data = json_decode($response_body, true);
3607
3608 if (isset($response_data['ok']) && $response_data['ok']) {
3609 $topic_id = $response_data['result']['message_thread_id'];
3610 update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
3611 update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
3612 }
3613 }
3614
3615 if (empty($topic_id)) {
3616 return false; // Failed to create topic
3617 }
3618 }
3619
3620 // Get recent chat history
3621 $history = get_option("mxchat_history_{$session_id}", []);
3622 $recent_history = array_slice($history, -5);
3623
3624 // Format conversation context for Telegram (HTML format)
3625 $conversation_context = "";
3626 if (!empty($recent_history)) {
3627 $conversation_context = "<b>Recent Conversation:</b>\n";
3628 foreach ($recent_history as $hist_message) {
3629 $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
3630 $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
3631 $conversation_context .= "{$role_display}: {$escaped_content}\n";
3632 }
3633 $conversation_context .= "\n";
3634 }
3635
3636 // Get user info
3637 $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
3638 $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
3639
3640 // Update session mode
3641 update_option("mxchat_mode_{$session_id}", 'agent');
3642
3643 // Send initial message to topic
3644 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3645 $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
3646 $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
3647 $topic_message .= "<b>User:</b> {$user_name}\n";
3648 $topic_message .= "<b>Email:</b> {$user_email}\n\n";
3649
3650 if (!empty($conversation_context)) {
3651 $topic_message .= $conversation_context;
3652 }
3653
3654 $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
3655 $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
3656 $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
3657
3658 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3659 'headers' => ['Content-Type' => 'application/json'],
3660 'body' => json_encode([
3661 'chat_id' => $telegram_group_id,
3662 'message_thread_id' => $topic_id,
3663 'text' => $topic_message,
3664 'parse_mode' => 'HTML'
3665 ])
3666 ]);
3667
3668 $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
3669 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
3670
3671 $this->fallbackResponse = [
3672 'text' => $success_message,
3673 'html' => '',
3674 'images' => [],
3675 'chat_mode' => 'agent'
3676 ];
3677
3678 wp_send_json([
3679 'success' => true,
3680 'text' => $success_message,
3681 'html' => '',
3682 'chat_mode' => 'agent',
3683 'session_id' => $session_id,
3684 'fallbackResponse' => $this->fallbackResponse
3685 ]);
3686 wp_die();
3687 }
3688
3689 /**
3690 * Generate topic name for Telegram forum
3691 */
3692 private function generate_telegram_topic_name($session_id) {
3693 $name = null;
3694 $email = null;
3695
3696 // Check logged in user
3697 if (is_user_logged_in()) {
3698 $current_user = wp_get_current_user();
3699 if (!empty($current_user->display_name)) {
3700 $name = $current_user->display_name;
3701 }
3702 if (!empty($current_user->user_email)) {
3703 $email = $current_user->user_email;
3704 }
3705 }
3706
3707 // Check session data
3708 if (empty($name)) {
3709 $name = get_option("mxchat_name_{$session_id}");
3710 }
3711 if (empty($email)) {
3712 $email = get_option("mxchat_email_{$session_id}");
3713 }
3714
3715 // Generate topic name
3716 $session_suffix = substr($session_id, -6);
3717
3718 if (!empty($name)) {
3719 // Clean name for topic (max 128 chars in Telegram)
3720 $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
3721 $clean_name = trim($clean_name);
3722 if (strlen($clean_name) > 50) {
3723 $clean_name = substr($clean_name, 0, 50);
3724 }
3725 return "Chat - {$clean_name} ({$session_suffix})";
3726 } elseif (!empty($email)) {
3727 // Use email prefix
3728 $email_prefix = explode('@', $email)[0];
3729 if (strlen($email_prefix) > 30) {
3730 $email_prefix = substr($email_prefix, 0, 30);
3731 }
3732 return "Chat - {$email_prefix} ({$session_suffix})";
3733 }
3734
3735 return "Chat - {$session_suffix}";
3736 }
3737
3738 /**
3739 * Send user message to Telegram agent
3740 */
3741 public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
3742 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3743 $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
3744 $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3745
3746 if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
3747 return false;
3748 }
3749
3750 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
3751 $user_message = "👤 <b>User:</b> {$escaped_message}";
3752
3753 $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3754 'headers' => ['Content-Type' => 'application/json'],
3755 'body' => json_encode([
3756 'chat_id' => $group_id,
3757 'message_thread_id' => $topic_id,
3758 'text' => $user_message,
3759 'parse_mode' => 'HTML'
3760 ])
3761 ]);
3762
3763 return !is_wp_error($response);
3764 }
3765
3766 /**
3767 * Handle incoming Telegram webhook
3768 */
3769 public function handle_telegram_webhook(WP_REST_Request $request) {
3770 $body = $request->get_body();
3771 $data = json_decode($body, true);
3772
3773 error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
3774
3775 // Handle message events from forum topics
3776 if (isset($data['message'])) {
3777 $message_data = $data['message'];
3778
3779 // Skip if not from a forum topic
3780 if (!isset($message_data['message_thread_id'])) {
3781 error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
3782 return new WP_REST_Response(['ok' => true]);
3783 }
3784
3785 // Skip bot messages
3786 if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
3787 error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
3788 return new WP_REST_Response(['ok' => true]);
3789 }
3790
3791 $chat_id = $message_data['chat']['id'] ?? '';
3792 $topic_id = $message_data['message_thread_id'];
3793 $message_text = $message_data['text'] ?? '';
3794 $message_id = $message_data['message_id'] ?? '';
3795 $from = $message_data['from'] ?? [];
3796 $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
3797 if (empty($agent_name)) {
3798 $agent_name = $from['username'] ?? 'Agent';
3799 }
3800
3801 error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
3802
3803 // Skip empty messages
3804 if (empty($message_text)) {
3805 error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
3806 return new WP_REST_Response(['ok' => true]);
3807 }
3808
3809 // Find session ID by topic ID - cast to string for comparison
3810 global $wpdb;
3811 $topic_id_str = strval($topic_id);
3812 $session_option = $wpdb->get_var(
3813 $wpdb->prepare(
3814 "SELECT option_name FROM {$wpdb->options}
3815 WHERE option_name LIKE %s
3816 AND option_value = %s",
3817 'mxchat_telegram_topic_%',
3818 $topic_id_str
3819 )
3820 );
3821
3822 error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
3823
3824 if ($session_option) {
3825 $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
3826 error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
3827
3828 // Verify the group ID matches
3829 $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
3830 error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
3831
3832 if (strval($stored_group_id) != strval($chat_id)) {
3833 error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
3834 return new WP_REST_Response(['ok' => true]);
3835 }
3836
3837 // Check for closure commands
3838 $lower_text = strtolower(trim($message_text));
3839 if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
3840 error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
3841 // End the live agent session
3842 update_option("mxchat_mode_{$session_id}", 'ai');
3843
3844 // Save disconnect message
3845 $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
3846 $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
3847
3848 // Notify in Telegram
3849 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3850 if (!empty($telegram_bot_token)) {
3851 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3852 'headers' => ['Content-Type' => 'application/json'],
3853 'body' => json_encode([
3854 'chat_id' => $chat_id,
3855 'message_thread_id' => $topic_id,
3856 'text' => "✅ Session closed. User returned to AI chatbot.",
3857 'parse_mode' => 'HTML'
3858 ])
3859 ]);
3860
3861 // Optionally close the topic
3862 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
3863 'headers' => ['Content-Type' => 'application/json'],
3864 'body' => json_encode([
3865 'chat_id' => $chat_id,
3866 'message_thread_id' => $topic_id
3867 ])
3868 ]);
3869 }
3870
3871 return new WP_REST_Response(['ok' => true]);
3872 }
3873
3874 // Deduplicate messages
3875 $message_key = md5($session_id . $message_id . $message_text);
3876 $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
3877
3878 if (in_array($message_key, $processed_messages)) {
3879 error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
3880 return new WP_REST_Response(['ok' => true]);
3881 }
3882
3883 $processed_messages[] = $message_key;
3884 if (count($processed_messages) > 50) {
3885 $processed_messages = array_slice($processed_messages, -50);
3886 }
3887 set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
3888
3889 // Save the agent message - format with agent name prefix for proper parsing
3890 $formatted_message = "Agent: {$agent_name} - {$message_text}";
3891 error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
3892
3893 $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
3894
3895 // Verify the message was saved to history
3896 $history = get_option("mxchat_history_{$session_id}", []);
3897 $last_message = end($history);
3898 error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
3899
3900 // Send confirmation back to Telegram
3901 $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
3902 if (!empty($telegram_bot_token)) {
3903 $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
3904 if (!get_transient($confirm_key)) {
3905 wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
3906 'headers' => ['Content-Type' => 'application/json'],
3907 'body' => json_encode([
3908 'chat_id' => $chat_id,
3909 'message_thread_id' => $topic_id,
3910 'text' => "✅ <i>Message sent to user</i>",
3911 'parse_mode' => 'HTML',
3912 'reply_to_message_id' => $message_id
3913 ])
3914 ]);
3915 set_transient($confirm_key, true, 300);
3916 }
3917 }
3918 } else {
3919 error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
3920 }
3921 } else {
3922 error_log('[MxChat Telegram DEBUG] No message in webhook data');
3923 }
3924
3925 return new WP_REST_Response(['ok' => true]);
3926 }
3927
3928 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
3929 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
3930 $channel_id = get_option("mxchat_channel_{$session_id}", '');
3931
3932 if (empty($slack_bot_token) || empty($channel_id)) {
3933 return false;
3934 }
3935
3936 $user_message = "💬 *User:* {$message}";
3937
3938 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
3939 'headers' => [
3940 'Content-Type' => 'application/json',
3941 'Authorization' => 'Bearer ' . $slack_bot_token
3942 ],
3943 'body' => json_encode([
3944 'channel' => $channel_id,
3945 'text' => $user_message,
3946 'mrkdwn' => true
3947 ])
3948 ]);
3949
3950 return !is_wp_error($response);
3951 }
3952 public function handle_slack_interaction(WP_REST_Request $request) {
3953 //error_log('Received Slack interaction');
3954
3955 $payload = json_decode($request->get_param('payload'), true);
3956 //error_log('Payload: ' . print_r($payload, true));
3957
3958 // Handle button click
3959 if ($payload['type'] === 'block_actions' && $payload['actions'][0]['action_id'] === 'reply_to_user') {
3960 $session_id = $payload['actions'][0]['value'];
3961 $trigger_id = $payload['trigger_id'];
3962
3963 // Get Bot Token from settings
3964 $slack_token = $this->options['live_agent_bot_token'] ?? '';
3965
3966 if (empty($slack_token)) {
3967 //error_log('Slack Bot Token not configured');
3968 return new WP_REST_Response(['error' => esc_html__('Bot token not configured', 'mxchat')], 400);
3969 }
3970 $response = wp_remote_post('https://slack.com/api/views.open', [
3971 'headers' => [
3972 'Content-Type' => 'application/json',
3973 'Authorization' => 'Bearer ' . $slack_token
3974 ],
3975 'body' => json_encode([
3976 'trigger_id' => $trigger_id,
3977 'view' => [
3978 'type' => 'modal',
3979 'callback_id' => 'reply_modal',
3980 'title' => [
3981 'type' => 'plain_text',
3982 'text' => __('Reply to User', 'mxchat')
3983 ],
3984 'submit' => [
3985 'type' => 'plain_text',
3986 'text' => __('Send', 'mxchat')
3987 ],
3988 'close' => [
3989 'type' => 'plain_text',
3990 'text' => __('Cancel', 'mxchat')
3991 ],
3992 'blocks' => [
3993 [
3994 'type' => 'input',
3995 'block_id' => 'reply_block',
3996 'label' => [
3997 'type' => 'plain_text',
3998 'text' => sprintf(__('Reply to session: %s', 'mxchat'), $session_id)
3999 ],
4000 'element' => [
4001 'type' => 'plain_text_input',
4002 'action_id' => 'message',
4003 'multiline' => true,
4004 'placeholder' => [
4005 'type' => 'plain_text',
4006 'text' => __('Type your message here...', 'mxchat')
4007 ]
4008 ]
4009 ]
4010 ],
4011 'private_metadata' => $session_id
4012 ]
4013 ])
4014 ]);
4015
4016 //error_log('Views.open response: ' . print_r($response, true));
4017
4018 // Return immediate acknowledgment
4019 return new WP_REST_Response(['ok' => true]);
4020 }
4021
4022 // Handle modal submission
4023 // Handle modal submission
4024 if ($payload['type'] === 'view_submission') {
4025 $session_id = $payload['view']['private_metadata'];
4026 $message = $payload['view']['state']['values']['reply_block']['message']['value'];
4027
4028 // Save the message (keep the message_id but don't include in response)
4029 $this->mxchat_save_chat_message($session_id, 'agent', $message);
4030
4031 // Keep the original response format for Slack
4032 return new WP_REST_Response([
4033 'response_action' => 'clear'
4034 ]);
4035 }
4036
4037 // Default acknowledgment
4038 return new WP_REST_Response(['ok' => true]);
4039 }
4040 public function mxchat_handle_agent_response(WP_REST_Request $request) {
4041 //error_log('Received agent response request');
4042 //error_log('Request data: ' . print_r($request->get_params(), true));
4043 // //error_log('Raw body: ' . file_get_contents('php://input'));
4044
4045 // Get the data from Slack's slash command format
4046 $command_text = $request->get_param('text');
4047 // //error_log('Command text: ' . $command_text);
4048
4049 if (empty($command_text)) {
4050 //error_log(esc_html__('Agent response error: No command text received', 'mxchat'));
4051 return new WP_REST_Response([
4052 'error' => esc_html__('Command text is required. Format: /reply session_id message', 'mxchat')
4053 ], 400);
4054 }
4055
4056 // Split the command text into session_id and message
4057 $parts = explode(' ', $command_text, 2);
4058 if (count($parts) !== 2) {
4059 //error_log('Agent response error: Invalid command format');
4060 return new WP_REST_Response([
4061 'error' => esc_html__('Invalid format. Use: /reply session_id message', 'mxchat')
4062 ], 400);
4063 }
4064
4065 $session_id = sanitize_text_field($parts[0]);
4066 $message = sanitize_text_field($parts[1]);
4067
4068 //error_log("Processing agent response - Session ID: $session_id, Message: $message");
4069
4070 // Save the message
4071 $message_id = $this->mxchat_save_chat_message($session_id, 'agent', $message);
4072
4073 if (!$message_id) {
4074 // //error_log('Failed to save agent message');
4075 return new WP_REST_Response([
4076 'error' => esc_html__('Failed to save message', 'mxchat')
4077 ], 500);
4078 }
4079
4080 // Return success response in Slack's expected format
4081 return new WP_REST_Response([
4082 'response_type' => 'in_channel',
4083 'text' => esc_html__("Message sent successfully to session $session_id", 'mxchat')
4084 ], 200);
4085 }
4086 public function mxchat_handle_switch_to_chatbot_intent($message, $user_id, $session_id) {
4087 // Update mode to AI
4088 update_option("mxchat_mode_{$session_id}", 'ai');
4089
4090 // Clear any existing PDF context to start fresh
4091 $this->clear_pdf_transients($session_id);
4092
4093 // Set the response with explicit chat_mode
4094 $this->fallbackResponse = [
4095 'text' => esc_html__('You are now chatting with the AI chatbot.', 'mxchat'),
4096 'html' => '',
4097 'images' => [],
4098 'chat_mode' => 'ai' // Ensure this is set
4099 ];
4100
4101 // Return the complete response array instead of just true
4102 return $this->fallbackResponse;
4103 }
4104
4105 public function handle_slack_messages(WP_REST_Request $request) {
4106 // Log the incoming request for debugging
4107 //error_log('Slack events request received: ' . $request->get_body());
4108
4109 $body = $request->get_body();
4110 $data = json_decode($body, true);
4111
4112 // Handle Slack URL verification
4113 if (isset($data['type']) && $data['type'] === 'url_verification') {
4114 //error_log('Slack URL verification challenge: ' . $data['challenge']);
4115 return new WP_REST_Response($data['challenge'], 200, ['Content-Type' => 'text/plain']);
4116 }
4117
4118 // IMPORTANT: Handle Slack's event deduplication
4119 if (isset($data['event_id'])) {
4120 $event_id = $data['event_id'];
4121 $processed_events = get_transient('mxchat_slack_events') ?: [];
4122
4123 // Check if we've already processed this event
4124 if (in_array($event_id, $processed_events)) {
4125 //error_log("Duplicate event detected: $event_id");
4126 return new WP_REST_Response(['ok' => true]);
4127 }
4128
4129 // Add this event to processed list
4130 $processed_events[] = $event_id;
4131 // Keep only last 100 events to prevent memory issues
4132 if (count($processed_events) > 100) {
4133 $processed_events = array_slice($processed_events, -100);
4134 }
4135 // Store for 1 hour
4136 set_transient('mxchat_slack_events', $processed_events, HOUR_IN_SECONDS);
4137 }
4138
4139 // Handle message events
4140 if (isset($data['event']) && $data['event']['type'] === 'message') {
4141 $event = $data['event'];
4142
4143 // Skip bot messages and messages with subtypes (like bot_message)
4144 if (isset($event['bot_id']) || isset($event['subtype'])) {
4145 return new WP_REST_Response(['ok' => true]);
4146 }
4147
4148 // Additional check: Skip if this is a threaded reply to our confirmation
4149 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
4150 return new WP_REST_Response(['ok' => true]);
4151 }
4152
4153 $channel_id = $event['channel'];
4154 $message_text = $event['text'] ?? '';
4155 $message_ts = $event['ts'] ?? '';
4156
4157 // Find session ID by looking for matching channel
4158 global $wpdb;
4159 $session_option = $wpdb->get_var(
4160 $wpdb->prepare(
4161 "SELECT option_name FROM {$wpdb->options}
4162 WHERE option_name LIKE 'mxchat_channel_%'
4163 AND option_value = %s",
4164 $channel_id
4165 )
4166 );
4167
4168 if ($session_option) {
4169 $session_id = str_replace('mxchat_channel_', '', $session_option);
4170
4171 // Create a unique key for this specific message
4172 $message_key = md5($session_id . $message_ts . $message_text);
4173 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
4174
4175 // Check if we've already processed this exact message
4176 if (in_array($message_key, $processed_messages)) {
4177 //error_log("Duplicate message detected for session $session_id");
4178 return new WP_REST_Response(['ok' => true]);
4179 }
4180
4181 // Add to processed messages
4182 $processed_messages[] = $message_key;
4183 // Keep only last 50 messages per session
4184 if (count($processed_messages) > 50) {
4185 $processed_messages = array_slice($processed_messages, -50);
4186 }
4187 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
4188
4189 // Save the agent message
4190 $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
4191
4192 // Send confirmation back to Slack (only once)
4193 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4194 if (!empty($slack_bot_token)) {
4195 // Use a transient to prevent duplicate confirmations
4196 $confirm_key = 'mxchat_confirm_' . $message_key;
4197 if (!get_transient($confirm_key)) {
4198 wp_remote_post('https://slack.com/api/chat.postMessage', [
4199 'headers' => [
4200 'Content-Type' => 'application/json',
4201 'Authorization' => 'Bearer ' . $slack_bot_token
4202 ],
4203 'body' => json_encode([
4204 'channel' => $channel_id,
4205 'text' => "✅ _Message sent to user_",
4206 'thread_ts' => $event['ts'] // Reply in thread
4207 ])
4208 ]);
4209 // Set transient to prevent duplicate confirmations
4210 set_transient($confirm_key, true, 300); // 5 minutes
4211 }
4212 }
4213 }
4214 }
4215
4216 return new WP_REST_Response(['ok' => true]);
4217 }
4218
4219 // For the word upload handler
4220 public function mxchat_handle_word_upload() {
4221 // Delegate to word handler
4222 $this->word_handler->mxchat_handle_word_upload();
4223 }
4224
4225 // For the word removal handler
4226 public function mxchat_handle_word_remove() {
4227 // Delegate to word handler
4228 $this->word_handler->mxchat_handle_word_remove();
4229 }
4230
4231 // For the word status check
4232 public function mxchat_check_word_status() {
4233 // Delegate to word handler
4234 $this->word_handler->mxchat_check_word_status();
4235 }
4236
4237
4238 private function mxchat_get_user_identifier() {
4239 return MxChat_User::mxchat_get_user_identifier();
4240 }
4241
4242 private function mxchat_generate_embedding($text, $api_key) {
4243 try {
4244 // Get options and selected model
4245 $options = get_option('mxchat_options');
4246 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4247
4248 // Determine endpoint and API key based on model
4249 if (strpos($selected_model, 'voyage') === 0) {
4250 $endpoint = 'https://api.voyageai.com/v1/embeddings';
4251 $api_key = $options['voyage_api_key'] ?? '';
4252
4253 // Check if Voyage API key is missing
4254 if (empty($api_key)) {
4255 //error_log('Voyage API key is missing');
4256 return [
4257 'error' => esc_html__('Voyage AI API key is not configured', 'mxchat'),
4258 'error_code' => 'missing_voyage_api_key'
4259 ];
4260 }
4261 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
4262 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
4263 $api_key = $options['gemini_api_key'] ?? '';
4264
4265 // Check if Gemini API key is missing
4266 if (empty($api_key)) {
4267 //error_log('Gemini API key is missing');
4268 return [
4269 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
4270 'error_code' => 'missing_gemini_api_key'
4271 ];
4272 }
4273 } else {
4274 $endpoint = 'https://api.openai.com/v1/embeddings';
4275 // Use the passed API key for OpenAI
4276
4277 // Check if OpenAI API key is missing
4278 if (empty($api_key)) {
4279 //error_log('OpenAI API key is missing');
4280 return [
4281 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
4282 'error_code' => 'missing_openai_api_key'
4283 ];
4284 }
4285 }
4286
4287 // Check if text is empty
4288 if (empty($text)) {
4289 //error_log('Empty text provided for embedding generation');
4290 return [
4291 'error' => esc_html__('No text provided for embedding generation', 'mxchat'),
4292 'error_code' => 'empty_embedding_text'
4293 ];
4294 }
4295
4296 // Prepare request body based on provider
4297 if (strpos($selected_model, 'gemini-embedding') === 0) {
4298 // Gemini API format
4299 $request_body = [
4300 'model' => 'models/' . $selected_model,
4301 'content' => [
4302 'parts' => [
4303 ['text' => $text]
4304 ]
4305 ],
4306 'outputDimensionality' => 1536
4307 ];
4308
4309 // Prepare headers for Gemini (API key as query parameter)
4310 $endpoint .= '?key=' . $api_key;
4311 $headers = [
4312 'Content-Type' => 'application/json'
4313 ];
4314 } else {
4315 // OpenAI/Voyage API format
4316 $request_body = [
4317 'input' => $text,
4318 'model' => $selected_model
4319 ];
4320
4321 // Add output_dimension for voyage-3-large
4322 if ($selected_model === 'voyage-3-large') {
4323 $request_body['output_dimension'] = 2048;
4324 }
4325
4326 // Prepare headers for OpenAI/Voyage
4327 $headers = [
4328 'Content-Type' => 'application/json',
4329 'Authorization' => 'Bearer ' . $api_key
4330 ];
4331 }
4332
4333 // Prepare request arguments
4334 $args = [
4335 'body' => wp_json_encode($request_body),
4336 'headers' => $headers,
4337 'timeout' => 60,
4338 'redirection' => 5,
4339 'blocking' => true,
4340 'httpversion' => '1.0',
4341 'sslverify' => true,
4342 ];
4343
4344 // Make the request
4345 $response = wp_remote_post($endpoint, $args);
4346
4347 // Handle WordPress errors
4348 if (is_wp_error($response)) {
4349 $error_message = $response->get_error_message();
4350 //error_log('Embedding Generation Error: ' . $error_message);
4351 return [
4352 'error' => esc_html__('Connection error when generating embeddings: ', 'mxchat') . esc_html($error_message),
4353 'error_code' => 'embedding_connection_error'
4354 ];
4355 }
4356
4357 // Check HTTP status code
4358 $status_code = wp_remote_retrieve_response_code($response);
4359 if ($status_code !== 200) {
4360 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4361
4362 $error_message = isset($response_body['error']['message'])
4363 ? $response_body['error']['message']
4364 : 'HTTP Error ' . $status_code;
4365
4366 $error_type = isset($response_body['error']['type'])
4367 ? $response_body['error']['type']
4368 : 'unknown';
4369
4370 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
4371
4372 // Handle specific error types
4373 switch ($error_type) {
4374 case 'invalid_request_error':
4375 if (strpos($error_message, 'API key') !== false) {
4376 return [
4377 'error' => esc_html__('Invalid API key for embedding generation. Please check your API key configuration.', 'mxchat'),
4378 'error_code' => 'embedding_invalid_api_key'
4379 ];
4380 }
4381 break;
4382
4383 case 'authentication_error':
4384 return [
4385 'error' => esc_html__('Authentication failed for embedding generation. Please check your API key.', 'mxchat'),
4386 'error_code' => 'embedding_auth_error'
4387 ];
4388
4389 case 'rate_limit_exceeded':
4390 return [
4391 'error' => esc_html__('Rate limit exceeded for embedding generation. Please try again later.', 'mxchat'),
4392 'error_code' => 'embedding_rate_limit'
4393 ];
4394
4395 case 'quota_exceeded':
4396 return [
4397 'error' => esc_html__('API quota exceeded for embedding generation. Please check your billing details.', 'mxchat'),
4398 'error_code' => 'embedding_quota_exceeded'
4399 ];
4400 }
4401
4402 // Generic error fallback
4403 return [
4404 'error' => esc_html__('Embedding API error - check embedding API key.: ', 'mxchat') . esc_html($error_message),
4405 'error_code' => 'embedding_api_error',
4406 'status_code' => $status_code
4407 ];
4408 }
4409
4410 $response_body = json_decode(wp_remote_retrieve_body($response), true);
4411
4412 // Handle different response formats based on provider
4413 if (strpos($selected_model, 'gemini-embedding') === 0) {
4414 // Gemini API response format
4415 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
4416 return $response_body['embedding']['values'];
4417 } else {
4418 //error_log('Invalid Gemini embedding response: ' . wp_json_encode($response_body));
4419 return [
4420 'error' => esc_html__('Received invalid embedding data from the Gemini API.', 'mxchat'),
4421 'error_code' => 'invalid_gemini_embedding_response'
4422 ];
4423 }
4424 } else {
4425 // OpenAI/Voyage API response format
4426 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
4427 return $response_body['data'][0]['embedding'];
4428 } else {
4429 //error_log('Invalid embedding response: ' . wp_json_encode($response_body));
4430 return [
4431 'error' => esc_html__('Received invalid embedding data from the API.', 'mxchat'),
4432 'error_code' => 'invalid_embedding_response'
4433 ];
4434 }
4435 }
4436 } catch (Exception $e) {
4437 //error_log('Embedding Exception: ' . $e->getMessage());
4438 return [
4439 'error' => esc_html__('System error when generating embeddings: ', 'mxchat') . esc_html($e->getMessage()),
4440 'error_code' => 'embedding_exception'
4441 ];
4442 }
4443 }
4444
4445
4446 private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
4447 error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
4448
4449 // Check for OpenAI Vector Store first (takes priority when enabled)
4450 $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
4451
4452 if ($bot_vectorstore_config['use_vectorstore']) {
4453 // Get current model to verify it's an OpenAI model
4454 $bot_options = $this->get_bot_options($bot_id);
4455 $mxchat_options = get_option('mxchat_options', array());
4456 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
4457 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
4458
4459 if ($this->is_openai_chat_model($selected_model)) {
4460 error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
4461 return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
4462 } else {
4463 error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
4464 }
4465 }
4466
4467 // Get bot-specific Pinecone configuration
4468 $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
4469
4470 // Debug: Log the Pinecone configuration
4471 error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
4472 error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
4473 error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
4474 error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
4475 error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
4476
4477 // Determine whether to use Pinecone based on bot configuration
4478 $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
4479
4480 error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
4481
4482 if ($use_pinecone) {
4483 return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
4484 } else {
4485 return $this->find_relevant_content_wordpress($user_embedding, $bot_id);
4486 }
4487 }
4488
4489 private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default') {
4490 global $wpdb;
4491 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
4492 $cache_key = 'mxchat_system_prompt_embeddings_' . $bot_id;
4493 $batch_size = 500;
4494
4495 // Initialize similarity analysis storage
4496 $this->last_similarity_analysis = [
4497 'knowledge_base_type' => 'WordPress Database',
4498 'bot_id' => $bot_id,
4499 'top_matches' => [],
4500 'threshold_used' => 0,
4501 'total_checked' => 0
4502 ];
4503
4504 // NEW: Initialize valid URLs array
4505 $valid_urls = [];
4506
4507 // Get bot-specific options for similarity threshold
4508 $bot_options = $this->get_bot_options($bot_id);
4509 $current_options = !empty($bot_options) ? $bot_options : $this->options;
4510
4511 // Retrieve embeddings from cache or database
4512 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
4513 if ($embeddings === false) {
4514 // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
4515 $embeddings = [];
4516 $offset = 0;
4517
4518 do {
4519 // Add bot_id filter if not default and if bot_metadata column exists
4520 $bot_filter = '';
4521 if ($bot_id !== 'default') {
4522 // Check if bot_metadata column exists
4523 $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
4524 if ($column_exists) {
4525 $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
4526 }
4527 }
4528
4529 $query = $wpdb->prepare(
4530 "SELECT id, embedding_vector, article_content, source_url, role_restriction
4531 FROM {$system_prompt_table}
4532 WHERE 1=1 {$bot_filter}
4533 LIMIT %d OFFSET %d",
4534 $batch_size,
4535 $offset
4536 );
4537
4538 $batch = $wpdb->get_results($query);
4539 if (empty($batch)) {
4540 break;
4541 }
4542
4543 $embeddings = array_merge($embeddings, $batch);
4544 $offset += $batch_size;
4545 unset($batch);
4546 } while (true);
4547
4548 if (empty($embeddings)) {
4549 // Store empty array for valid URLs since no content found
4550 $this->current_valid_urls = [];
4551 return '';
4552 }
4553
4554 // Cache embeddings for future use (but note: this now includes content and role restrictions)
4555 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
4556 }
4557
4558 // Get knowledge manager instance for role checking
4559 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4560
4561 // Get base similarity threshold from bot options or default options
4562 $similarity_threshold = isset($current_options['similarity_threshold'])
4563 ? ((int) $current_options['similarity_threshold']) / 100
4564 : 0.35;
4565
4566 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4567
4568 // Calculate similarities and build results array
4569 $all_similarities = [];
4570 $url_groups = array(); // NEW: Group by source_url for chunk reassembly
4571
4572 foreach ($embeddings as $embedding) {
4573 $database_embedding = $embedding->embedding_vector
4574 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
4575 : null;
4576
4577 if (is_array($database_embedding) && is_array($user_embedding)) {
4578 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
4579
4580 // Check role access
4581 $role_restriction = $embedding->role_restriction ?? 'public';
4582 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
4583
4584 // Store ALL similarities for testing (top 10)
4585 $source_display = '';
4586 $source_url = $embedding->source_url ?? '';
4587 if (!empty($source_url) && $source_url !== '#') {
4588 $source_display = $source_url;
4589 } else {
4590 $content_preview = strip_tags($embedding->article_content ?? '');
4591 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
4592 $source_display = substr(trim($content_preview), 0, 50) . '...';
4593 }
4594
4595 // Parse chunk metadata for display
4596 $article_content_for_parse = $embedding->article_content ?? '';
4597 $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
4598 $is_chunk = $parsed_for_display['is_chunked'];
4599 $chunk_meta = $parsed_for_display['metadata'];
4600
4601 $all_similarities[] = [
4602 'document_id' => $embedding->id,
4603 'similarity' => $similarity,
4604 'similarity_percentage' => round($similarity * 100, 2),
4605 'above_threshold' => $similarity >= $similarity_threshold,
4606 'source_display' => $source_display,
4607 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
4608 'used_for_context' => false,
4609 'role_restriction' => $role_restriction,
4610 'has_access' => $has_access,
4611 'filtered_out' => !$has_access,
4612 'is_chunk' => $is_chunk,
4613 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
4614 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
4615 ];
4616
4617 // Only consider results above threshold AND with access for content retrieval
4618 if ($similarity >= $similarity_threshold && $has_access && !empty($source_url)) {
4619 // Parse chunk metadata if present
4620 $article_content = $embedding->article_content ?? '';
4621 $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
4622 $is_chunked = $parsed['is_chunked'];
4623 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4624 $text_content = $parsed['text'];
4625
4626 // Group by source URL
4627 if (!isset($url_groups[$source_url])) {
4628 $url_groups[$source_url] = array(
4629 'source_url' => $source_url,
4630 'best_score' => 0,
4631 'is_chunked' => $is_chunked,
4632 'chunks' => array(),
4633 'single_text' => '',
4634 'single_id' => null
4635 );
4636 }
4637
4638 // Track best score for this URL
4639 if ($similarity > $url_groups[$source_url]['best_score']) {
4640 $url_groups[$source_url]['best_score'] = $similarity;
4641 }
4642
4643 // Store chunk info or single text
4644 if ($is_chunked) {
4645 $url_groups[$source_url]['is_chunked'] = true;
4646 $url_groups[$source_url]['chunks'][] = array(
4647 'id' => $embedding->id,
4648 'score' => $similarity,
4649 'chunk_index' => $chunk_index,
4650 'text' => $text_content
4651 );
4652 } else {
4653 $url_groups[$source_url]['single_text'] = $text_content;
4654 $url_groups[$source_url]['single_id'] = $embedding->id;
4655 }
4656 }
4657 }
4658
4659 unset($database_embedding);
4660 }
4661
4662 // Sort ALL similarities for testing display (highest first)
4663 usort($all_similarities, function ($a, $b) {
4664 return $b['similarity'] <=> $a['similarity'];
4665 });
4666
4667 // Sort URL groups by best score (highest first)
4668 uasort($url_groups, function($a, $b) {
4669 return $b['best_score'] <=> $a['best_score'];
4670 });
4671
4672 // Get RAG sources limit from options (default 6, min 3, max 10)
4673 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 6;
4674 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
4675 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
4676
4677 // Take top N unique URLs based on user setting
4678 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
4679
4680 // Track which document IDs are used for context
4681 $used_document_ids = [];
4682 foreach ($top_urls as $group) {
4683 if ($group['is_chunked']) {
4684 foreach ($group['chunks'] as $chunk) {
4685 $used_document_ids[] = $chunk['id'];
4686 }
4687 } elseif ($group['single_id']) {
4688 $used_document_ids[] = $group['single_id'];
4689 }
4690 }
4691
4692 // Update the all_similarities array to mark which were actually used
4693 foreach ($all_similarities as &$similarity_item) {
4694 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
4695 }
4696
4697 // Store top 10 for testing panel
4698 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
4699 $this->last_similarity_analysis['total_checked'] = count($embeddings);
4700
4701 // Initialize final content
4702 $content = '';
4703 $matches_used = 0;
4704 $total_chunks_used = 0;
4705 $max_total_chunks = 30; // Hard cap on total chunks to prevent excessive token usage
4706
4707 // Check if citation links are enabled (default to 'on' for backwards compatibility)
4708 // Use fresh options to ensure we get the latest setting value
4709 $fresh_options = get_option('mxchat_options', []);
4710 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
4711
4712 // Build content from top URLs
4713 foreach ($top_urls as $source_url => $group) {
4714 // Stop if we've hit the total chunk limit
4715 if ($total_chunks_used >= $max_total_chunks) {
4716 break;
4717 }
4718
4719 $full_text = '';
4720 $chunks_in_this_source = 1; // Default for non-chunked content
4721
4722 if ($group['is_chunked']) {
4723 // Calculate how many chunks we can still use
4724 $chunks_remaining = $max_total_chunks - $total_chunks_used;
4725
4726 // Fetch chunks for this URL with limit
4727 $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
4728
4729 // If fetching all chunks fails, fall back to matched chunks
4730 if (empty($full_text)) {
4731 // Sort matched chunks by index and concatenate
4732 usort($group['chunks'], function($a, $b) {
4733 return $a['chunk_index'] <=> $b['chunk_index'];
4734 });
4735
4736 $chunk_texts = array();
4737 $chunks_in_this_source = 0;
4738 foreach ($group['chunks'] as $chunk) {
4739 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
4740 break;
4741 }
4742 $chunk_texts[] = $chunk['text'];
4743 $chunks_in_this_source++;
4744 }
4745 $full_text = implode("\n\n", $chunk_texts);
4746 }
4747 } else {
4748 $full_text = $group['single_text'];
4749 $chunks_in_this_source = 1;
4750 }
4751
4752 if (!empty($full_text)) {
4753 // Strip URLs from content if citation links are disabled
4754 if (!$citation_links_enabled) {
4755 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
4756 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
4757 }
4758
4759 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
4760 $content .= $full_text . "\n\n";
4761
4762 // Only include citation URLs if citation links are enabled
4763 if ($citation_links_enabled && !empty($source_url) && $source_url !== '#') {
4764 $valid_urls[] = $source_url;
4765 $content .= "URL: " . $source_url . "\n\n";
4766 }
4767
4768 // Extract any URLs from the text content itself (only if citation links enabled)
4769 if ($citation_links_enabled) {
4770 preg_match_all(
4771 '#\bhttps?://[^\s<>"\']+#i',
4772 $full_text,
4773 $content_urls
4774 );
4775 if (!empty($content_urls[0])) {
4776 $valid_urls = array_merge($valid_urls, $content_urls[0]);
4777 }
4778 }
4779
4780 $matches_used++;
4781 $total_chunks_used += $chunks_in_this_source;
4782 }
4783 }
4784
4785 // NEW: Store unique valid URLs for validation
4786 $this->current_valid_urls = array_unique($valid_urls);
4787
4788 // Add response guidelines
4789 if (empty($top_urls)) {
4790 $content = "No reference information was found for this query.\n\n";
4791 } else {
4792 // Build response guidelines based on citation links setting
4793 $content .= "\n## Response Guidelines ##\n" .
4794 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
4795 "Be conversational and friendly, but never mention your knowledge base or training data. " .
4796 "If you don't have specific information or are uncertain about any details, it's always " .
4797 "better to honestly say you don't know rather than making up or guessing at answers. " .
4798 "When information is incomplete, let them know you are unsure.\n\n";
4799
4800 // Only add hyperlink instructions if citation links are enabled
4801 if ($citation_links_enabled) {
4802 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
4803 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
4804 } else {
4805 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
4806 "Simply provide helpful answers based on the reference information without citing sources.";
4807 }
4808 }
4809
4810 return trim($content);
4811 }
4812
4813 /**
4814 * Fetch and reassemble chunks for a URL from WordPress database
4815 *
4816 * @param string $source_url The source URL to fetch chunks for
4817 * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
4818 * @param int &$chunk_count Reference to store the actual number of chunks returned
4819 * @return string Reassembled content from chunks
4820 */
4821 private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
4822 global $wpdb;
4823 $table = $wpdb->prefix . 'mxchat_system_prompt_content';
4824
4825 // Fetch all rows with this source_url
4826 $rows = $wpdb->get_results($wpdb->prepare(
4827 "SELECT article_content FROM {$table}
4828 WHERE source_url = %s
4829 ORDER BY id ASC",
4830 $source_url
4831 ));
4832
4833 if (empty($rows)) {
4834 $chunk_count = 0;
4835 return '';
4836 }
4837
4838 // Parse and sort chunks by index
4839 $chunks = array();
4840 foreach ($rows as $row) {
4841 $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
4842
4843 if ($parsed['is_chunked']) {
4844 $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
4845 $chunks[$chunk_index] = $parsed['text'];
4846 } else {
4847 // Non-chunked content - just return it
4848 $chunks[] = $parsed['text'];
4849 }
4850 }
4851
4852 // Sort by chunk index
4853 ksort($chunks);
4854
4855 // Apply chunk limit if specified
4856 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
4857 $chunks = array_slice($chunks, 0, $max_chunks, true);
4858 }
4859
4860 // Store actual chunk count
4861 $chunk_count = count($chunks);
4862
4863 // Reassemble content
4864 return implode("\n\n", $chunks);
4865 }
4866
4867 private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
4868 global $wpdb;
4869
4870 error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
4871 error_log(" - bot_id: " . $bot_id);
4872 error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
4873 error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
4874
4875 // Use bot-specific config or fall back to default
4876 if ($bot_config === null) {
4877 $bot_config = $this->get_bot_pinecone_config($bot_id);
4878 }
4879
4880 $api_key = $bot_config['api_key'] ?? '';
4881 $host = $bot_config['host'] ?? '';
4882 $namespace = $bot_config['namespace'] ?? '';
4883
4884 error_log("MXCHAT DEBUG: Pinecone query parameters:");
4885 error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
4886 error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
4887 error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
4888
4889 // Initialize similarity analysis storage
4890 $this->last_similarity_analysis = [
4891 'knowledge_base_type' => 'Pinecone',
4892 'bot_id' => $bot_id,
4893 'namespace' => $namespace,
4894 'top_matches' => [],
4895 'threshold_used' => 0,
4896 'total_checked' => 0
4897 ];
4898
4899 // NEW: Initialize valid URLs array
4900 $valid_urls = [];
4901
4902 if (empty($host) || empty($api_key)) {
4903 error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
4904 error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
4905 error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
4906 // Store empty array for valid URLs since we can't proceed
4907 $this->current_valid_urls = [];
4908 return '';
4909 }
4910
4911 // Get knowledge manager instance for role checking
4912 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4913
4914 // Get the similarity threshold from the bot options or main options
4915 $bot_options = $this->get_bot_options($bot_id);
4916 $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
4917
4918 $similarity_threshold = isset($current_options['similarity_threshold'])
4919 ? ((int) $current_options['similarity_threshold']) / 100
4920 : 0.35;
4921
4922 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
4923
4924 // Prepare the query request for Pinecone
4925 $api_endpoint = "https://{$host}/query";
4926
4927 $request_body = array(
4928 'vector' => $user_embedding,
4929 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
4930 'includeMetadata' => true,
4931 'includeValues' => true
4932 );
4933
4934 // Add namespace if specified for this bot
4935 if (!empty($namespace)) {
4936 $request_body['namespace'] = $namespace;
4937 }
4938
4939 error_log("MXCHAT DEBUG: About to call Pinecone API");
4940 error_log(" - Endpoint: " . $api_endpoint);
4941 error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
4942
4943 $response = wp_remote_post($api_endpoint, array(
4944 'headers' => array(
4945 'Api-Key' => $api_key,
4946 'accept' => 'application/json',
4947 'content-type' => 'application/json'
4948 ),
4949 'body' => wp_json_encode($request_body),
4950 'timeout' => 30
4951 ));
4952
4953 if (is_wp_error($response)) {
4954 error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
4955 // Store empty array for valid URLs
4956 $this->current_valid_urls = [];
4957 return '';
4958 }
4959
4960 $response_code = wp_remote_retrieve_response_code($response);
4961 error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
4962
4963 if ($response_code !== 200) {
4964 $response_body = wp_remote_retrieve_body($response);
4965 error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
4966 // Store empty array for valid URLs
4967 $this->current_valid_urls = [];
4968 return '';
4969 }
4970
4971 // ADD DETAILED DEBUG SECTION HERE
4972 $response_body = wp_remote_retrieve_body($response);
4973 error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
4974
4975 $results = json_decode($response_body, true);
4976
4977 if (json_last_error() !== JSON_ERROR_NONE) {
4978 error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
4979 error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
4980 // Store empty array for valid URLs
4981 $this->current_valid_urls = [];
4982 return '';
4983 }
4984
4985 error_log("MXCHAT DEBUG: Pinecone response structure:");
4986 error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
4987 error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
4988
4989 if (empty($results['matches'])) {
4990 error_log("MXCHAT DEBUG: No matches found in Pinecone response");
4991 error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
4992 // Store empty array for valid URLs
4993 $this->current_valid_urls = [];
4994 return '';
4995 }
4996
4997 error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
4998
4999 // Log first match details for debugging
5000 if (!empty($results['matches'][0])) {
5001 $first_match = $results['matches'][0];
5002 error_log("MXCHAT DEBUG: First match details:");
5003 error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
5004 error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
5005 if (isset($first_match['metadata'])) {
5006 error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
5007 }
5008 }
5009
5010 // Initialize the final content
5011 $content = '';
5012 $matches_used = 0;
5013 $matches_used_for_context = [];
5014 $total_chunks_used = 0;
5015 $max_total_chunks = 30; // Hard cap on total chunks to prevent excessive token usage
5016
5017 // Check if citation links are enabled (default to 'on' for backwards compatibility)
5018 // Use fresh options to ensure we get the latest setting value
5019 $fresh_options = get_option('mxchat_options', []);
5020 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
5021
5022 // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
5023 $url_groups = array();
5024
5025 foreach ($results['matches'] as $index => $match) {
5026 // Skip if similarity is below threshold
5027 if ($match['score'] < $similarity_threshold) {
5028 continue;
5029 }
5030
5031 $metadata = $match['metadata'] ?? array();
5032 $source_url = $metadata['source_url'] ?? '';
5033
5034 if (empty($source_url)) {
5035 continue;
5036 }
5037
5038 // LAZY ROLE CHECK: Only check role for content we're actually considering
5039 $match_id = $match['id'] ?? '';
5040 $role_restriction = $this->get_single_vector_role($match_id, $metadata);
5041 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5042
5043 // Skip if user doesn't have access
5044 if (!$has_access) {
5045 continue;
5046 }
5047
5048 // Group by source URL
5049 if (!isset($url_groups[$source_url])) {
5050 $url_groups[$source_url] = array(
5051 'source_url' => $source_url,
5052 'best_score' => 0,
5053 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
5054 'chunks' => array(),
5055 'single_text' => ''
5056 );
5057 }
5058
5059 // Track best score for this URL
5060 if ($match['score'] > $url_groups[$source_url]['best_score']) {
5061 $url_groups[$source_url]['best_score'] = $match['score'];
5062 }
5063
5064 // Store chunk info or single text
5065 if ($url_groups[$source_url]['is_chunked']) {
5066 $url_groups[$source_url]['chunks'][] = array(
5067 'id' => $match_id,
5068 'score' => $match['score'],
5069 'chunk_index' => $metadata['chunk_index'] ?? 0,
5070 'text' => $metadata['text'] ?? ''
5071 );
5072 } else {
5073 // Non-chunked content - just store the text
5074 $url_groups[$source_url]['single_text'] = $metadata['text'] ?? '';
5075 $url_groups[$source_url]['single_id'] = $match_id;
5076 }
5077 }
5078
5079 // Sort URL groups by best score (highest first)
5080 uasort($url_groups, function($a, $b) {
5081 return $b['best_score'] <=> $a['best_score'];
5082 });
5083
5084 // Get RAG sources limit from options (default 6, min 3, max 10)
5085 $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 6;
5086 if ($rag_sources_limit < 3) $rag_sources_limit = 3;
5087 if ($rag_sources_limit > 10) $rag_sources_limit = 10;
5088
5089 // Take top N unique URLs based on user setting
5090 $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
5091
5092 // Track which match IDs are actually used for context
5093 foreach ($top_urls as $group) {
5094 if ($group['is_chunked']) {
5095 foreach ($group['chunks'] as $chunk) {
5096 $matches_used_for_context[] = $chunk['id'];
5097 }
5098 } elseif (!empty($group['single_id'])) {
5099 $matches_used_for_context[] = $group['single_id'];
5100 }
5101 }
5102
5103 // Build content from top URLs
5104 foreach ($top_urls as $source_url => $group) {
5105 // Stop if we've hit the total chunk limit
5106 if ($total_chunks_used >= $max_total_chunks) {
5107 break;
5108 }
5109
5110 $full_text = '';
5111 $chunks_in_this_source = 1; // Default for non-chunked content
5112
5113 if ($group['is_chunked']) {
5114 // Calculate how many chunks we can still use
5115 $chunks_remaining = $max_total_chunks - $total_chunks_used;
5116
5117 // Fetch chunks for this URL with limit
5118 $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
5119
5120 // If fetching all chunks fails, fall back to matched chunks
5121 if (empty($full_text)) {
5122 // Sort matched chunks by index and concatenate
5123 usort($group['chunks'], function($a, $b) {
5124 return $a['chunk_index'] <=> $b['chunk_index'];
5125 });
5126
5127 $chunk_texts = array();
5128 $chunks_in_this_source = 0;
5129 foreach ($group['chunks'] as $chunk) {
5130 if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
5131 break;
5132 }
5133 $chunk_texts[] = $chunk['text'];
5134 $chunks_in_this_source++;
5135 }
5136 $full_text = implode("\n\n", $chunk_texts);
5137 }
5138 } else {
5139 $full_text = $group['single_text'];
5140 $chunks_in_this_source = 1;
5141 }
5142
5143 if (!empty($full_text)) {
5144 // Strip URLs from content if citation links are disabled
5145 if (!$citation_links_enabled) {
5146 $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
5147 $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
5148 }
5149
5150 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5151 $content .= $full_text . "\n\n";
5152
5153 // Only include citation URLs if citation links are enabled
5154 if ($citation_links_enabled && !empty($source_url) && $source_url !== '#') {
5155 $valid_urls[] = $source_url;
5156 $content .= "URL: " . $source_url . "\n\n";
5157 }
5158
5159 // Extract any URLs from the text content itself (only if citation links enabled)
5160 if ($citation_links_enabled) {
5161 preg_match_all(
5162 '#\bhttps?://[^\s<>"\']+#i',
5163 $full_text,
5164 $content_urls
5165 );
5166 if (!empty($content_urls[0])) {
5167 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5168 }
5169 }
5170
5171 $matches_used++;
5172 $total_chunks_used += $chunks_in_this_source;
5173 }
5174 }
5175
5176 // Process ALL matches for testing data (top 10) - with role checking for testing display
5177 $all_matches = [];
5178 foreach ($results['matches'] as $index => $match) {
5179 if ($index >= 10) break; // Limit to top 10 for testing
5180
5181 $match_id = $match['id'] ?? '';
5182
5183 // Check role access for testing display (use cache if available)
5184 $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
5185 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
5186
5187 $source_display = '';
5188 if (!empty($match['metadata']['source_url'])) {
5189 $source_display = $match['metadata']['source_url'];
5190 } else {
5191 $content_preview = strip_tags($match['metadata']['text'] ?? '');
5192 $content_preview = preg_replace('/\s+/', ' ', $content_preview);
5193 $source_display = substr(trim($content_preview), 0, 50) . '...';
5194 }
5195
5196 $match_id_for_display = $match['id'] ?? $index;
5197
5198 // Check for chunk metadata in Pinecone
5199 $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
5200 $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
5201 $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
5202
5203 // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
5204 if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
5205 $is_chunk = true;
5206 }
5207
5208 $all_matches[] = [
5209 'document_id' => $match_id_for_display,
5210 'similarity' => $match['score'],
5211 'similarity_percentage' => round($match['score'] * 100, 2),
5212 'above_threshold' => $match['score'] >= $similarity_threshold,
5213 'source_display' => $source_display,
5214 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
5215 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
5216 'role_restriction' => $role_restriction,
5217 'has_access' => $has_access,
5218 'filtered_out' => !$has_access,
5219 'is_chunk' => $is_chunk,
5220 'chunk_index' => $chunk_index,
5221 'total_chunks' => $total_chunks
5222 ];
5223 }
5224
5225 // Store for testing panel
5226 $this->last_similarity_analysis['top_matches'] = $all_matches;
5227 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
5228
5229 // NEW: Store unique valid URLs for validation
5230 $this->current_valid_urls = array_unique($valid_urls);
5231
5232 // Add response guidelines
5233 if ($matches_used === 0) {
5234 $content = "No reference information was found for this query.\n\n";
5235 } else {
5236 // Build response guidelines based on citation links setting
5237 $content .= "\n## Response Guidelines ##\n" .
5238 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5239 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5240 "If you don't have specific information or are uncertain about any details, it's always " .
5241 "better to honestly say you don't know rather than making up or guessing at answers. " .
5242 "When information is incomplete, let them know you are unsure.\n\n";
5243
5244 // Only add hyperlink instructions if citation links are enabled
5245 if ($citation_links_enabled) {
5246 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5247 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5248 } else {
5249 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5250 "Simply provide helpful answers based on the reference information without citing sources.";
5251 }
5252 }
5253
5254 return trim($content);
5255 }
5256
5257 /**
5258 * Get role restriction for a single vector (with caching)
5259 */
5260 private function get_single_vector_role($vector_id, $metadata = array()) {
5261 global $wpdb;
5262
5263 if (empty($vector_id)) {
5264 return 'public';
5265 }
5266
5267 // Check cache first
5268 $cache_key = 'mxchat_vector_role_' . $vector_id;
5269 $cached_role = wp_cache_get($cache_key, 'mxchat_vector_roles');
5270
5271 if ($cached_role !== false) {
5272 return $cached_role;
5273 }
5274
5275 $role_restriction = 'public';
5276
5277 // First try Pinecone metadata
5278 if (!empty($metadata['role_restriction'])) {
5279 $role_restriction = $metadata['role_restriction'];
5280 } else {
5281 // Check WordPress table for user-modified roles
5282 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
5283 $stored_role = $wpdb->get_var($wpdb->prepare(
5284 "SELECT role_restriction FROM {$roles_table} WHERE vector_id = %s",
5285 $vector_id
5286 ));
5287
5288 if ($stored_role) {
5289 $role_restriction = $stored_role;
5290 }
5291 }
5292
5293 // Cache individual role for 1 hour
5294 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
5295
5296 return $role_restriction;
5297 }
5298
5299 /**
5300 * Fetch and reassemble all chunks for a URL from Pinecone
5301 *
5302 * @param string $source_url The source URL to fetch chunks for
5303 * @param array $bot_config Bot-specific Pinecone configuration
5304 * @return string Reassembled content from all chunks
5305 */
5306 private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
5307 $api_key = $bot_config['api_key'] ?? '';
5308 $host = $bot_config['host'] ?? '';
5309 $namespace = $bot_config['namespace'] ?? '';
5310
5311 if (empty($host) || empty($api_key)) {
5312 $chunk_count = 0;
5313 return '';
5314 }
5315
5316 $base_hash = md5($source_url);
5317
5318 // Use Pinecone list API to find all chunk vectors with this prefix
5319 $list_url = "https://{$host}/vectors/list";
5320
5321 // Limit to max_chunks if specified, otherwise fetch up to 100
5322 $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
5323
5324 $list_body = array(
5325 'prefix' => $base_hash . '_chunk_',
5326 'limit' => $fetch_limit
5327 );
5328
5329 if (!empty($namespace)) {
5330 $list_body['namespace'] = $namespace;
5331 }
5332
5333 $list_response = wp_remote_post($list_url, array(
5334 'headers' => array(
5335 'Api-Key' => $api_key,
5336 'accept' => 'application/json',
5337 'content-type' => 'application/json'
5338 ),
5339 'body' => wp_json_encode($list_body),
5340 'timeout' => 30
5341 ));
5342
5343 if (is_wp_error($list_response)) {
5344 //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
5345 return '';
5346 }
5347
5348 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
5349
5350 if (empty($list_data['vectors'])) {
5351 //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
5352 return '';
5353 }
5354
5355 // Extract vector IDs
5356 $vector_ids = array();
5357 foreach ($list_data['vectors'] as $vector) {
5358 if (isset($vector['id'])) {
5359 $vector_ids[] = $vector['id'];
5360 }
5361 }
5362
5363 if (empty($vector_ids)) {
5364 return '';
5365 }
5366
5367 // Fetch all chunk content
5368 $fetch_url = "https://{$host}/vectors/fetch";
5369
5370 $fetch_body = array(
5371 'ids' => $vector_ids
5372 );
5373
5374 if (!empty($namespace)) {
5375 $fetch_body['namespace'] = $namespace;
5376 }
5377
5378 $fetch_response = wp_remote_post($fetch_url, array(
5379 'headers' => array(
5380 'Api-Key' => $api_key,
5381 'accept' => 'application/json',
5382 'content-type' => 'application/json'
5383 ),
5384 'body' => wp_json_encode($fetch_body),
5385 'timeout' => 30
5386 ));
5387
5388 if (is_wp_error($fetch_response)) {
5389 //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
5390 return '';
5391 }
5392
5393 $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
5394
5395 if (empty($fetch_data['vectors'])) {
5396 return '';
5397 }
5398
5399 // Sort chunks by index and reassemble
5400 $chunks = array();
5401 foreach ($fetch_data['vectors'] as $id => $vector) {
5402 $metadata = $vector['metadata'] ?? array();
5403 $chunk_index = $metadata['chunk_index'] ?? 0;
5404 $text = $metadata['text'] ?? '';
5405
5406 // Store chunk with its index
5407 $chunks[$chunk_index] = $text;
5408 }
5409
5410 // Sort by chunk index
5411 ksort($chunks);
5412
5413 // Apply chunk limit if specified
5414 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
5415 $chunks = array_slice($chunks, 0, $max_chunks, true);
5416 }
5417
5418 // Store actual chunk count
5419 $chunk_count = count($chunks);
5420
5421 // Reassemble content
5422 return implode("\n\n", $chunks);
5423 }
5424
5425 /**
5426 * Search for relevant content using OpenAI Vector Store (File Search)
5427 *
5428 * @param string $user_query The user's query text
5429 * @param string $bot_id The bot ID
5430 * @param array $vectorstore_config Vector Store configuration
5431 * @return string Formatted context string with references
5432 */
5433 private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
5434 error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
5435 error_log(" - bot_id: " . $bot_id);
5436 error_log(" - user_query length: " . strlen($user_query));
5437
5438 // Get OpenAI API key
5439 $mxchat_options = get_option('mxchat_options', array());
5440 $api_key = $mxchat_options['api_key'] ?? '';
5441
5442 if (empty($api_key)) {
5443 error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
5444 $this->current_valid_urls = [];
5445 return '';
5446 }
5447
5448 // Get Vector Store configuration
5449 if (empty($vectorstore_config)) {
5450 $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
5451 }
5452
5453 $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
5454 $max_results = $vectorstore_config['max_results'] ?? 5;
5455
5456 if (empty($vectorstore_ids_string)) {
5457 error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
5458 $this->current_valid_urls = [];
5459 return '';
5460 }
5461
5462 // Parse Vector Store IDs
5463 $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
5464 $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
5465
5466 error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5467 error_log("MXCHAT DEBUG: Max results: " . $max_results);
5468
5469 // Initialize similarity analysis storage
5470 $this->last_similarity_analysis = [
5471 'knowledge_base_type' => 'OpenAI Vector Store',
5472 'bot_id' => $bot_id,
5473 'vectorstore_ids' => $vectorstore_ids,
5474 'top_matches' => [],
5475 'threshold_used' => 0,
5476 'total_checked' => 0
5477 ];
5478
5479 $valid_urls = [];
5480
5481 // Get the selected model
5482 $bot_options = $this->get_bot_options($bot_id);
5483 $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
5484 $selected_model = $current_options['model'] ?? 'gpt-5.1-chat-latest';
5485
5486 // Verify it's an OpenAI model
5487 if (!$this->is_openai_chat_model($selected_model)) {
5488 error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
5489 $this->current_valid_urls = [];
5490 return '';
5491 }
5492
5493 // Use OpenAI Responses API with file_search tool
5494 $request_body = array(
5495 'model' => $selected_model,
5496 'input' => $user_query,
5497 'tools' => array(
5498 array(
5499 'type' => 'file_search',
5500 'vector_store_ids' => $vectorstore_ids,
5501 'max_num_results' => intval($max_results)
5502 )
5503 ),
5504 'include' => array('output[*].file_search_call.search_results')
5505 );
5506
5507 error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
5508 error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
5509 error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
5510 error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
5511 error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
5512 error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
5513
5514 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
5515 'headers' => array(
5516 'Authorization' => 'Bearer ' . $api_key,
5517 'Content-Type' => 'application/json'
5518 ),
5519 'body' => wp_json_encode($request_body),
5520 'timeout' => 60
5521 ));
5522
5523 if (is_wp_error($response)) {
5524 error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
5525 $this->current_valid_urls = [];
5526 return '';
5527 }
5528
5529 $response_code = wp_remote_retrieve_response_code($response);
5530 error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
5531
5532 $response_body = wp_remote_retrieve_body($response);
5533 error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
5534
5535 if ($response_code !== 200) {
5536 error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
5537 $this->current_valid_urls = [];
5538 return '';
5539 }
5540 $result = json_decode($response_body, true);
5541
5542 if (json_last_error() !== JSON_ERROR_NONE) {
5543 error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
5544 $this->current_valid_urls = [];
5545 return '';
5546 }
5547
5548 // Debug: Log the structure of the result
5549 error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
5550 if (isset($result['output'])) {
5551 error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
5552 foreach ($result['output'] as $idx => $out) {
5553 error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
5554 error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
5555 }
5556 } else {
5557 error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
5558 }
5559
5560 // Extract file search results from the response
5561 $content = '';
5562 $matches_used = 0;
5563 $all_matches = [];
5564
5565 // The Responses API returns output array with tool results
5566 if (isset($result['output']) && is_array($result['output'])) {
5567 foreach ($result['output'] as $output_item) {
5568 // Look for file_search_call results
5569 if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
5570 error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
5571 error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
5572
5573 // Check for search_results in the output item directly
5574 $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
5575 error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
5576
5577 if (empty($search_results)) {
5578 error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
5579 error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
5580 }
5581
5582 foreach ($search_results as $index => $search_result) {
5583 $filename = $search_result['filename'] ?? '';
5584 $score = $search_result['score'] ?? 0;
5585 $text_content = '';
5586
5587 // Extract text content from the result
5588 // The text can be directly on the result OR nested under content array
5589 if (isset($search_result['text']) && !empty($search_result['text'])) {
5590 // Direct text field (OpenAI's actual format)
5591 $text_content = $search_result['text'];
5592 error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
5593 } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
5594 // Nested content array format
5595 foreach ($search_result['content'] as $content_item) {
5596 if (isset($content_item['text'])) {
5597 $text_content .= $content_item['text'] . "\n";
5598 }
5599 }
5600 error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
5601 } else {
5602 error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
5603 }
5604
5605 if (!empty($text_content)) {
5606 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5607 $content .= trim($text_content) . "\n\n";
5608
5609 if (!empty($filename)) {
5610 $content .= "Source: " . $filename . "\n\n";
5611 }
5612
5613 // Extract URLs from content
5614 preg_match_all(
5615 '#\bhttps?://[^\s<>"\']+#i',
5616 $text_content,
5617 $content_urls
5618 );
5619 if (!empty($content_urls[0])) {
5620 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5621 }
5622
5623 $matches_used++;
5624 }
5625
5626 // Store for similarity analysis
5627 $all_matches[] = [
5628 'document_id' => $filename ?: ('result_' . $index),
5629 'similarity' => $score,
5630 'similarity_percentage' => round($score * 100, 2),
5631 'above_threshold' => true,
5632 'source_display' => $filename,
5633 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5634 'used_for_context' => true,
5635 'role_restriction' => 'public',
5636 'has_access' => true,
5637 'filtered_out' => false
5638 ];
5639 }
5640 }
5641
5642 // Also check for message content with annotations (citations)
5643 if (isset($output_item['type']) && $output_item['type'] === 'message') {
5644 if (isset($output_item['content']) && is_array($output_item['content'])) {
5645 foreach ($output_item['content'] as $content_block) {
5646 if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
5647 foreach ($content_block['annotations'] as $annotation) {
5648 if (isset($annotation['filename'])) {
5649 $filename = $annotation['filename'];
5650 $score = $annotation['score'] ?? 0;
5651 $text_content = '';
5652
5653 if (isset($annotation['content']) && is_array($annotation['content'])) {
5654 foreach ($annotation['content'] as $ann_content) {
5655 if (isset($ann_content['text'])) {
5656 $text_content .= $ann_content['text'] . "\n";
5657 }
5658 }
5659 }
5660
5661 if (!empty($text_content) && $matches_used < $max_results) {
5662 $content .= "## Reference " . ($matches_used + 1) . " ##\n";
5663 $content .= trim($text_content) . "\n\n";
5664 $content .= "Source: " . $filename . "\n\n";
5665
5666 preg_match_all(
5667 '#\bhttps?://[^\s<>"\']+#i',
5668 $text_content,
5669 $content_urls
5670 );
5671 if (!empty($content_urls[0])) {
5672 $valid_urls = array_merge($valid_urls, $content_urls[0]);
5673 }
5674
5675 $matches_used++;
5676
5677 $all_matches[] = [
5678 'document_id' => $filename,
5679 'similarity' => $score,
5680 'similarity_percentage' => round($score * 100, 2),
5681 'above_threshold' => true,
5682 'source_display' => $filename,
5683 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
5684 'used_for_context' => true,
5685 'role_restriction' => 'public',
5686 'has_access' => true,
5687 'filtered_out' => false
5688 ];
5689 }
5690 }
5691 }
5692 }
5693 }
5694 }
5695 }
5696 }
5697 }
5698
5699 // Store for testing panel
5700 $this->last_similarity_analysis['top_matches'] = $all_matches;
5701 $this->last_similarity_analysis['total_checked'] = count($all_matches);
5702
5703 // Store unique valid URLs for validation
5704 $this->current_valid_urls = array_unique($valid_urls);
5705
5706 error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
5707 error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
5708 error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
5709 error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
5710 if ($matches_used > 0) {
5711 error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
5712 }
5713
5714 // Check if citation links are enabled
5715 $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
5716
5717 // Add response guidelines
5718 if ($matches_used === 0) {
5719 error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
5720 $content = "No reference information was found for this query.\n\n";
5721 } else {
5722 // Build response guidelines based on citation links setting
5723 $content .= "\n## Response Guidelines ##\n" .
5724 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
5725 "Be conversational and friendly, but never mention your knowledge base or training data. " .
5726 "If you don't have specific information or are uncertain about any details, it's always " .
5727 "better to honestly say you don't know rather than making up or guessing at answers. " .
5728 "When information is incomplete, let them know you are unsure.\n\n";
5729
5730 // Only add hyperlink instructions if citation links are enabled
5731 if ($citation_links_enabled) {
5732 $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
5733 "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
5734 } else {
5735 $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
5736 "Simply provide helpful answers based on the reference information without citing sources.";
5737 }
5738 }
5739
5740 error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
5741
5742 return trim($content);
5743 }
5744
5745 /**
5746 * Check if the given model is an OpenAI chat model
5747 *
5748 * @param string $model The model ID
5749 * @return bool True if it's an OpenAI model
5750 */
5751 private function is_openai_chat_model($model) {
5752 $openai_prefixes = array('gpt-', 'o1-', 'o3-');
5753 foreach ($openai_prefixes as $prefix) {
5754 if (strpos($model, $prefix) === 0) {
5755 return true;
5756 }
5757 }
5758 return false;
5759 }
5760
5761 /**
5762 * Get bot-specific Vector Store configuration
5763 *
5764 * @param string $bot_id The bot ID
5765 * @return array Configuration array
5766 */
5767 private function get_bot_vectorstore_config($bot_id = 'default') {
5768 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
5769
5770 // Default global settings
5771 $default_config = array(
5772 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
5773 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
5774 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
5775 );
5776
5777 // Allow multi-bot plugin to override with bot-specific settings
5778 $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
5779
5780 // Preserve max_results from global settings if not set in bot config
5781 if (!isset($bot_config['max_results'])) {
5782 $bot_config['max_results'] = $default_config['max_results'];
5783 }
5784
5785 return $bot_config;
5786 }
5787
5788 private function mxchat_find_relevant_products($user_embedding) {
5789 //error_log('MXChat Vector Search: Starting product search...');
5790
5791 // Retrieve the add-on settings from the database
5792 $addon_options = get_option('mxchat_pinecone_addon_options', array());
5793
5794 // Determine whether Pinecone is enabled
5795 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
5796
5797 //error_log('Pinecone enabled flag: ' . $use_pinecone);
5798
5799 if ($use_pinecone === 1) {
5800 //error_log('MXChat Vector Search: Using Pinecone database for products');
5801 return $this->find_relevant_products_pinecone($user_embedding);
5802 } else {
5803 //error_log('MXChat Vector Search: Using WordPress database for products');
5804 return $this->find_relevant_products_wordpress($user_embedding);
5805 }
5806 }
5807 private function find_relevant_products_wordpress($user_embedding) {
5808 global $wpdb;
5809 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5810 $cache_key = 'mxchat_system_prompt_embeddings';
5811 $batch_size = 500;
5812
5813 // Original WordPress database search logic
5814 // [Previous implementation remains the same]
5815 $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
5816 if ($embeddings === false) {
5817 $embeddings = [];
5818 $offset = 0;
5819
5820 do {
5821 $query = $wpdb->prepare(
5822 "SELECT id, embedding_vector
5823 FROM {$system_prompt_table}
5824 LIMIT %d OFFSET %d",
5825 $batch_size,
5826 $offset
5827 );
5828
5829 $batch = $wpdb->get_results($query);
5830 if (empty($batch)) {
5831 break;
5832 }
5833
5834 $embeddings = array_merge($embeddings, $batch);
5835 $offset += $batch_size;
5836
5837 unset($batch);
5838
5839 } while (true);
5840
5841 if (empty($embeddings)) {
5842 return '';
5843 }
5844 wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
5845 }
5846
5847 $relevant_results = [];
5848 foreach ($embeddings as $embedding) {
5849 $database_embedding = $embedding->embedding_vector
5850 ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
5851 : null;
5852 if (is_array($database_embedding) && is_array($user_embedding)) {
5853 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
5854 $relevant_results[] = [
5855 'id' => $embedding->id,
5856 'similarity' => $similarity
5857 ];
5858 }
5859 unset($database_embedding);
5860 }
5861
5862 // Use fixed threshold for products
5863 $similarity_threshold = 0.85;
5864
5865 $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
5866 return $result['similarity'] >= $similarity_threshold;
5867 });
5868 usort($relevant_results, function ($a, $b) {
5869 return $b['similarity'] <=> $a['similarity'];
5870 });
5871
5872 $top_results = array_slice($relevant_results, 0, 3);
5873 $content = '';
5874
5875 foreach ($top_results as $result) {
5876 $chunk_content = $this->fetch_content_with_product_links($result['id']);
5877 $content .= $chunk_content . "\n\n";
5878 }
5879
5880 return trim($content);
5881 }
5882
5883
5884 private function find_relevant_products_pinecone($user_embedding) {
5885 //error_log('Starting Pinecone product search...');
5886
5887 $options = get_option('mxchat_pinecone_addon_options', array());
5888 $api_key = $options['mxchat_pinecone_api_key'] ?? '';
5889 $host = $options['mxchat_pinecone_host'] ?? '';
5890
5891 if (empty($host) || empty($api_key)) {
5892 //error_log('Pinecone credentials not properly configured for product search');
5893 return '';
5894 }
5895
5896 $similarity_threshold = 0.85;
5897 $api_endpoint = "https://{$host}/query";
5898
5899 $request_body = array(
5900 'vector' => $user_embedding,
5901 'topK' => 5,
5902 'includeMetadata' => true,
5903 'includeValues' => true,
5904 'filter' => array(
5905 'type' => 'product'
5906 )
5907 );
5908
5909 //error_log('Sending request to Pinecone with body: ' . wp_json_encode($request_body));
5910
5911 $response = wp_remote_post($api_endpoint, array(
5912 'headers' => array(
5913 'Api-Key' => $api_key,
5914 'accept' => 'application/json',
5915 'content-type' => 'application/json'
5916 ),
5917 'body' => wp_json_encode($request_body),
5918 'timeout' => 30
5919 ));
5920
5921 if (is_wp_error($response)) {
5922 //error_log('Pinecone product query error: ' . $response->get_error_message());
5923 return '';
5924 }
5925
5926 $response_code = wp_remote_retrieve_response_code($response);
5927 //error_log('Pinecone response code: ' . $response_code);
5928
5929 if ($response_code !== 200) {
5930 //error_log('Pinecone API error during product search: ' . wp_remote_retrieve_body($response));
5931 return '';
5932 }
5933
5934 $results = json_decode(wp_remote_retrieve_body($response), true);
5935 //error_log('Pinecone raw response: ' . wp_remote_retrieve_body($response));
5936
5937 if (empty($results['matches'])) {
5938 //error_log('No matches found in Pinecone response');
5939 return '';
5940 }
5941
5942 $content = '';
5943 foreach ($results['matches'] as $match) {
5944 if ($match['score'] < $similarity_threshold) {
5945 //error_log("Match below threshold: " . $match['score']);
5946 continue;
5947 }
5948
5949 if (!empty($match['metadata']['text'])) {
5950 $content .= $match['metadata']['text'];
5951 if (!empty($match['metadata']['source_url'])) {
5952 $content .= "\n\nFor more details, check out this product: " . esc_url($match['metadata']['source_url']);
5953 }
5954 $content .= "\n\n";
5955 }
5956 }
5957
5958 return trim($content);
5959 }
5960
5961
5962 private function fetch_content_with_product_links($most_relevant_id) {
5963 global $wpdb;
5964 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
5965
5966 // Fetch the article content and associated product URL
5967 $query = $wpdb->prepare("SELECT article_content, source_url FROM {$system_prompt_table} WHERE id = %d", $most_relevant_id);
5968 $result = $wpdb->get_row($query);
5969
5970 if ($result) {
5971 // Append the product link to the content if available
5972 $content = $result->article_content;
5973 if (!empty($result->source_url)) {
5974 $content .= "\n\nFor more details, check out this product: " . esc_url($result->source_url);
5975 }
5976 return $content;
5977 }
5978
5979 return null;
5980 }
5981
5982 /**
5983 * Get system instructions for a specific bot or default
5984 * Checks for multi-bot add-on and uses bot-specific instructions if available
5985 * Automatically strips URLs if citation links are disabled
5986 * Replaces {visitor_name} placeholder with actual visitor name if available
5987 *
5988 * @param string $bot_id The bot ID to get instructions for
5989 * @param string $session_id Optional session ID to lookup visitor name
5990 */
5991 private function get_system_instructions($bot_id = 'default', $session_id = '') {
5992 $instructions = '';
5993
5994 // Check if multi-bot add-on is active
5995 if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
5996 // Get bot-specific options from multi-bot add-on
5997 $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
5998
5999 // If bot has custom system instructions, use those
6000 if (!empty($bot_options['system_prompt_instructions'])) {
6001 $instructions = $bot_options['system_prompt_instructions'];
6002 }
6003 }
6004
6005 // Fall back to default system instructions
6006 if (empty($instructions)) {
6007 $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
6008 }
6009
6010 // Check if citation links are disabled - if so, strip URLs from instructions
6011 $fresh_options = get_option('mxchat_options', []);
6012 $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6013
6014 if (!$citation_links_enabled && !empty($instructions)) {
6015 $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
6016 $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
6017 }
6018
6019 // Replace {visitor_name} placeholder with actual visitor name if available
6020 if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
6021 $name_option_key = "mxchat_name_{$session_id}";
6022 $visitor_name = get_option($name_option_key, '');
6023
6024 if (!empty($visitor_name)) {
6025 $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
6026 } else {
6027 // Remove placeholder if no name is available
6028 $instructions = str_ireplace('{visitor_name}', '', $instructions);
6029 $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
6030 }
6031 }
6032
6033 return $instructions;
6034 }
6035 /**
6036 * Get the current bot ID from session or request context
6037 */
6038 private function get_current_bot_id($session_id = '') {
6039 // First, check if bot_id is passed in the current request
6040 if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
6041 return sanitize_key($_POST['bot_id']);
6042 }
6043
6044 // If not in POST, try to get it from session data
6045 if (!empty($session_id)) {
6046 $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
6047 if (!empty($bot_id)) {
6048 return $bot_id;
6049 }
6050 }
6051
6052 // Fall back to default
6053 return 'default';
6054 }
6055 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') {
6056 try {
6057 if (!$relevant_content) {
6058 $error_response = [
6059 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
6060 'error_code' => 'no_relevant_content'
6061 ];
6062
6063 if ($testing_data !== null) {
6064 $error_response['testing_data'] = $testing_data;
6065 }
6066
6067 return $error_response;
6068 }
6069
6070 if (!is_array($conversation_history)) {
6071 $conversation_history = array();
6072 }
6073
6074 // Check if this is an OpenRouter model
6075 if ($selected_model === 'openrouter') {
6076 // Get the actual OpenRouter model from options
6077 $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
6078
6079 if (empty($openrouter_selected_model)) {
6080 $error_response = [
6081 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
6082 'error_code' => 'no_openrouter_model_selected'
6083 ];
6084 if ($testing_data !== null) {
6085 $error_response['testing_data'] = $testing_data;
6086 }
6087 return $error_response;
6088 }
6089
6090 if (empty($openrouter_api_key)) {
6091 $error_response = [
6092 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
6093 'error_code' => 'missing_openrouter_api_key'
6094 ];
6095 if ($testing_data !== null) {
6096 $error_response['testing_data'] = $testing_data;
6097 }
6098 return $error_response;
6099 }
6100
6101 if ($streaming) {
6102 return $this->mxchat_generate_response_openrouter_stream(
6103 $openrouter_selected_model,
6104 $openrouter_api_key,
6105 $conversation_history,
6106 $relevant_content,
6107 $session_id,
6108 $testing_data
6109 );
6110 } else {
6111 $response = $this->mxchat_generate_response_openrouter(
6112 $openrouter_selected_model,
6113 $openrouter_api_key,
6114 $conversation_history,
6115 $relevant_content
6116 );
6117 }
6118
6119 if (is_array($response) && isset($response['error'])) {
6120 if ($testing_data !== null) {
6121 $response['testing_data'] = $testing_data;
6122 }
6123 return $response;
6124 }
6125
6126 return $response;
6127 }
6128
6129 // Extract model prefix to determine the provider
6130 $model_parts = explode('-', $selected_model);
6131 $provider = strtolower($model_parts[0]);
6132
6133 // Handle model selection based on provider prefix
6134 switch ($provider) {
6135 case 'gemini':
6136 if (empty($gemini_api_key)) {
6137 $error_response = [
6138 'error' => esc_html__('Google Gemini API key is not configured', 'mxchat'),
6139 'error_code' => 'missing_gemini_api_key'
6140 ];
6141 if ($testing_data !== null) {
6142 $error_response['testing_data'] = $testing_data;
6143 }
6144 return $error_response;
6145 }
6146 $response = $this->mxchat_generate_response_gemini(
6147 $selected_model,
6148 $gemini_api_key,
6149 $conversation_history,
6150 $relevant_content
6151 );
6152 break;
6153
6154 case 'claude':
6155 if (empty($claude_api_key)) {
6156 $error_response = [
6157 'error' => esc_html__('Claude API key is not configured', 'mxchat'),
6158 'error_code' => 'missing_claude_api_key'
6159 ];
6160 if ($testing_data !== null) {
6161 $error_response['testing_data'] = $testing_data;
6162 }
6163 return $error_response;
6164 }
6165 if ($streaming) {
6166 return $this->mxchat_generate_response_claude_stream(
6167 $selected_model,
6168 $claude_api_key,
6169 $conversation_history,
6170 $relevant_content,
6171 $session_id,
6172 $testing_data
6173 );
6174 } else {
6175 $response = $this->mxchat_generate_response_claude(
6176 $selected_model,
6177 $claude_api_key,
6178 $conversation_history,
6179 $relevant_content
6180 );
6181 }
6182 break;
6183
6184 case 'grok':
6185 if (empty($xai_api_key)) {
6186 $error_response = [
6187 'error' => esc_html__('X.AI API key is not configured', 'mxchat'),
6188 'error_code' => 'missing_xai_api_key'
6189 ];
6190 if ($testing_data !== null) {
6191 $error_response['testing_data'] = $testing_data;
6192 }
6193 return $error_response;
6194 }
6195 if ($streaming) {
6196 return $this->mxchat_generate_response_xai_stream(
6197 $selected_model,
6198 $xai_api_key,
6199 $conversation_history,
6200 $relevant_content,
6201 $session_id,
6202 $testing_data
6203 );
6204 } else {
6205 $response = $this->mxchat_generate_response_xai(
6206 $selected_model,
6207 $xai_api_key,
6208 $conversation_history,
6209 $relevant_content
6210 );
6211 }
6212 break;
6213
6214 case 'deepseek':
6215 if (empty($deepseek_api_key)) {
6216 $error_response = [
6217 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
6218 'error_code' => 'missing_deepseek_api_key'
6219 ];
6220 if ($testing_data !== null) {
6221 $error_response['testing_data'] = $testing_data;
6222 }
6223 return $error_response;
6224 }
6225 if ($streaming) {
6226 return $this->mxchat_generate_response_deepseek_stream(
6227 $selected_model,
6228 $deepseek_api_key,
6229 $conversation_history,
6230 $relevant_content,
6231 $session_id,
6232 $testing_data
6233 );
6234 } else {
6235 $response = $this->mxchat_generate_response_deepseek(
6236 $selected_model,
6237 $deepseek_api_key,
6238 $conversation_history,
6239 $relevant_content
6240 );
6241 }
6242 break;
6243
6244 case 'gpt':
6245 case 'o1':
6246 if (empty($api_key)) {
6247 $error_response = [
6248 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6249 'error_code' => 'missing_openai_api_key'
6250 ];
6251 if ($testing_data !== null) {
6252 $error_response['testing_data'] = $testing_data;
6253 }
6254 return $error_response;
6255 }
6256
6257 // Check if web search is enabled for this OpenAI model
6258 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6259 // Models that don't support web search
6260 $unsupported_web_search_models = array('gpt-4.1-nano');
6261 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6262
6263 if ($web_search_enabled && $model_supports_web_search) {
6264 // Use Responses API with web search
6265 return $this->mxchat_generate_response_openai_web_search(
6266 $selected_model,
6267 $api_key,
6268 $conversation_history,
6269 $relevant_content,
6270 $session_id,
6271 $testing_data,
6272 $streaming
6273 );
6274 } elseif ($streaming) {
6275 return $this->mxchat_generate_response_openai_stream(
6276 $selected_model,
6277 $api_key,
6278 $conversation_history,
6279 $relevant_content,
6280 $session_id,
6281 $testing_data
6282 );
6283 } else {
6284 $response = $this->mxchat_generate_response_openai(
6285 $selected_model,
6286 $api_key,
6287 $conversation_history,
6288 $relevant_content
6289 );
6290 }
6291 break;
6292
6293 default:
6294 if (empty($api_key)) {
6295 $error_response = [
6296 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
6297 'error_code' => 'missing_openai_api_key'
6298 ];
6299 if ($testing_data !== null) {
6300 $error_response['testing_data'] = $testing_data;
6301 }
6302 return $error_response;
6303 }
6304
6305 // Check if web search is enabled (default case also handles OpenAI models)
6306 $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
6307 $unsupported_web_search_models = array('gpt-4.1-nano');
6308 $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
6309
6310 if ($web_search_enabled && $model_supports_web_search) {
6311 return $this->mxchat_generate_response_openai_web_search(
6312 $selected_model,
6313 $api_key,
6314 $conversation_history,
6315 $relevant_content,
6316 $session_id,
6317 $testing_data,
6318 $streaming
6319 );
6320 } elseif ($streaming) {
6321 return $this->mxchat_generate_response_openai_stream(
6322 $selected_model,
6323 $api_key,
6324 $conversation_history,
6325 $relevant_content,
6326 $session_id,
6327 $testing_data
6328 );
6329 } else {
6330 $response = $this->mxchat_generate_response_openai(
6331 $selected_model,
6332 $api_key,
6333 $conversation_history,
6334 $relevant_content
6335 );
6336 }
6337 break;
6338 }
6339
6340 if (is_array($response) && isset($response['error'])) {
6341 if ($testing_data !== null) {
6342 $response['testing_data'] = $testing_data;
6343 }
6344 return $response;
6345 }
6346
6347 return $response;
6348
6349 } catch (Exception $e) {
6350 $error_response = [
6351 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
6352 'error_code' => 'system_exception',
6353 'exception_details' => $e->getMessage()
6354 ];
6355
6356 if ($testing_data !== null) {
6357 $error_response['testing_data'] = $testing_data;
6358 }
6359
6360 return $error_response;
6361 }
6362 }
6363 private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6364 try {
6365 $bot_id = $this->get_current_bot_id($session_id);
6366 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6367
6368 if (!is_array($conversation_history)) {
6369 $conversation_history = array();
6370 }
6371
6372 $formatted_conversation = array();
6373
6374 $formatted_conversation[] = array(
6375 'role' => 'system',
6376 'content' => $system_prompt_instructions . " " . $relevant_content
6377 );
6378
6379 foreach ($conversation_history as $message) {
6380 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6381 $role = $message['role'];
6382 if ($role === 'bot' || $role === 'agent') {
6383 $role = 'assistant';
6384 }
6385 if (!in_array($role, ['system', 'assistant', 'user'])) {
6386 $role = 'user';
6387 }
6388 $formatted_conversation[] = array(
6389 'role' => $role,
6390 'content' => $message['content']
6391 );
6392 }
6393 }
6394
6395 if (headers_sent() || !function_exists('curl_init')) {
6396 $regular_response = $this->mxchat_generate_response_openrouter(
6397 $selected_model,
6398 $openrouter_api_key,
6399 $conversation_history,
6400 $relevant_content
6401 );
6402
6403 // Save bot response to transcript
6404 if (!empty($regular_response) && !empty($session_id)) {
6405 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6406 }
6407
6408 $response_data = [
6409 'text' => $regular_response,
6410 'html' => '',
6411 'session_id' => $session_id
6412 ];
6413
6414 if ($testing_data !== null) {
6415 $response_data['testing_data'] = $testing_data;
6416 }
6417
6418 header('Content-Type: application/json');
6419 echo json_encode($response_data);
6420 return true;
6421 }
6422
6423 $body = json_encode([
6424 'model' => $selected_model,
6425 'messages' => $formatted_conversation,
6426 'temperature' => 1,
6427 'stream' => true
6428 ]);
6429
6430 // Setup streaming headers now that we know we're actually streaming
6431 $this->setup_streaming_headers();
6432
6433 $ch = curl_init();
6434 curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
6435 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6436 curl_setopt($ch, CURLOPT_POST, true);
6437 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6438 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6439 'Content-Type: application/json',
6440 'Authorization: Bearer ' . $openrouter_api_key,
6441 'HTTP-Referer: ' . home_url(),
6442 'X-Title: ' . get_bloginfo('name')
6443 ));
6444 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6445 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6446
6447 $full_response = '';
6448 $stream_started = false;
6449 $buffer = '';
6450
6451 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6452 if (!$stream_started && $testing_data !== null) {
6453 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6454 flush();
6455 $stream_started = true;
6456 }
6457
6458 $buffer .= $data;
6459 $lines = explode("\n", $buffer);
6460 $buffer = array_pop($lines);
6461
6462 foreach ($lines as $line) {
6463 if (trim($line) === '') {
6464 continue;
6465 }
6466
6467 if (strpos($line, 'data: ') !== 0) {
6468 continue;
6469 }
6470
6471 $json_str = substr($line, 6);
6472
6473 if (trim($json_str) === '[DONE]') {
6474 echo "data: [DONE]\n\n";
6475 flush();
6476 continue;
6477 }
6478
6479 $json = json_decode(trim($json_str), true);
6480 if ($json && isset($json['choices'][0]['delta']['content'])) {
6481 $content = $json['choices'][0]['delta']['content'];
6482 $full_response .= $content;
6483
6484 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6485 flush();
6486 }
6487 }
6488
6489 return strlen($data);
6490 });
6491
6492 $response = curl_exec($ch);
6493 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6494
6495 if (curl_errno($ch) || $http_code !== 200) {
6496 curl_close($ch);
6497
6498 $regular_response = $this->mxchat_generate_response_openrouter(
6499 $selected_model,
6500 $openrouter_api_key,
6501 $conversation_history,
6502 $relevant_content
6503 );
6504
6505 $response_data = [
6506 'text' => $regular_response,
6507 'html' => '',
6508 'session_id' => $session_id
6509 ];
6510
6511 if ($testing_data !== null) {
6512 $response_data['testing_data'] = $testing_data;
6513 }
6514
6515 header('Content-Type: application/json');
6516 echo json_encode($response_data);
6517 return true;
6518 }
6519
6520 curl_close($ch);
6521
6522 if (!empty($full_response) && !empty($session_id)) {
6523 // Prepare RAG context for streaming response
6524 $rag_context_for_storage = null;
6525 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6526 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6527
6528 if ($has_rag_data || $has_action_data) {
6529 $rag_context_for_storage = [];
6530
6531 if ($has_rag_data) {
6532 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6533 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6534 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6535 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6536 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6537 }
6538
6539 if ($has_action_data) {
6540 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6541 }
6542 }
6543 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6544 }
6545
6546 return true;
6547
6548 } catch (Exception $e) {
6549 $regular_response = $this->mxchat_generate_response_openrouter(
6550 $selected_model,
6551 $openrouter_api_key,
6552 $conversation_history,
6553 $relevant_content
6554 );
6555
6556 $response_data = [
6557 'text' => $regular_response,
6558 'html' => '',
6559 'session_id' => $session_id
6560 ];
6561
6562 if ($testing_data !== null) {
6563 $response_data['testing_data'] = $testing_data;
6564 }
6565
6566 header('Content-Type: application/json');
6567 echo json_encode($response_data);
6568 return true;
6569 }
6570 }
6571 private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
6572 try {
6573 $bot_id = $this->get_current_bot_id($session_id);
6574
6575 // Get system prompt instructions using centralized function
6576 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6577
6578 // Ensure conversation_history is an array
6579 if (!is_array($conversation_history)) {
6580 $conversation_history = array();
6581 }
6582
6583 // Format conversation history for OpenAI
6584 $formatted_conversation = array();
6585
6586 $formatted_conversation[] = array(
6587 'role' => 'system',
6588 'content' => $system_prompt_instructions . " " . $relevant_content
6589 );
6590
6591 foreach ($conversation_history as $message) {
6592 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6593 $role = $message['role'];
6594 if ($role === 'bot' || $role === 'agent') {
6595 $role = 'assistant';
6596 }
6597 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
6598 $role = 'user';
6599 }
6600 $formatted_conversation[] = array(
6601 'role' => $role,
6602 'content' => $message['content']
6603 );
6604 }
6605 }
6606
6607 // Check if we can actually stream
6608 if (headers_sent() || !function_exists('curl_init')) {
6609 // Fallback to regular response with testing data
6610 $regular_response = $this->mxchat_generate_response_openai(
6611 $selected_model,
6612 $api_key,
6613 $conversation_history,
6614 $relevant_content
6615 );
6616
6617 // Save bot response to transcript
6618 if (!empty($regular_response) && !empty($session_id)) {
6619 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
6620 }
6621
6622 $response_data = [
6623 'text' => $regular_response,
6624 'html' => '',
6625 'session_id' => $session_id
6626 ];
6627
6628 if ($testing_data !== null) {
6629 $response_data['testing_data'] = $testing_data;
6630 }
6631
6632 header('Content-Type: application/json');
6633 echo json_encode($response_data);
6634 return true;
6635 }
6636
6637 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
6638 $is_gpt5_model = (
6639 strpos($selected_model, 'gpt-5') === 0 ||
6640 $selected_model === 'gpt-5.2' ||
6641 $selected_model === 'gpt-5.1-2025-11-13' ||
6642 $selected_model === 'gpt-5' ||
6643 $selected_model === 'gpt-5-mini' ||
6644 $selected_model === 'gpt-5-nano'
6645 );
6646
6647 // Build request body with optimal settings for fast streaming
6648 $request_body = [
6649 'model' => $selected_model,
6650 'messages' => $formatted_conversation,
6651 'temperature' => 1,
6652 'stream' => true
6653 ];
6654
6655 // Add reasoning_effort only for GPT-5 models (not GPT-5.2 which doesn't support it)
6656 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
6657 // GPT-5.1 uses 'low' instead of 'minimal'
6658 if ($selected_model === 'gpt-5.1-2025-11-13') {
6659 $request_body['reasoning_effort'] = 'low';
6660 } else {
6661 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
6662 }
6663 }
6664
6665 $body = json_encode($request_body);
6666
6667 // Setup streaming headers now that we know we're actually streaming
6668 $this->setup_streaming_headers();
6669
6670 // Use cURL for streaming support
6671 $ch = curl_init();
6672 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
6673 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
6674 curl_setopt($ch, CURLOPT_POST, true);
6675 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
6676 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
6677 'Content-Type: application/json',
6678 'Authorization: Bearer ' . $api_key
6679 ));
6680 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
6681 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
6682
6683 $full_response = ''; // Accumulate full response for saving
6684 $stream_started = false;
6685 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
6686
6687 // Buffer control for real-time streaming
6688 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
6689 // Send testing data as the first event if available
6690 if (!$stream_started && $testing_data !== null) {
6691 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
6692 flush();
6693 $stream_started = true;
6694 }
6695
6696 // CRITICAL FIX: Append new data to buffer
6697 $buffer .= $data;
6698
6699 // Process complete lines only
6700 $lines = explode("\n", $buffer);
6701
6702 // CRITICAL FIX: Keep the last incomplete line in the buffer
6703 // The last element might be incomplete, so keep it in buffer
6704 $buffer = array_pop($lines);
6705
6706 foreach ($lines as $line) {
6707 // Skip empty lines
6708 if (trim($line) === '') {
6709 continue;
6710 }
6711
6712 // Only process lines that start with "data: "
6713 if (strpos($line, 'data: ') !== 0) {
6714 continue;
6715 }
6716
6717 $json_str = substr($line, 6); // Remove 'data: ' prefix
6718
6719 if (trim($json_str) === '[DONE]') {
6720 echo "data: [DONE]\n\n";
6721 flush();
6722 continue;
6723 }
6724
6725 // Try to decode JSON
6726 $json = json_decode(trim($json_str), true);
6727 if ($json && isset($json['choices'][0]['delta']['content'])) {
6728 $content = $json['choices'][0]['delta']['content'];
6729 $full_response .= $content; // Accumulate the full response
6730
6731 // Send as SSE format
6732 echo "data: " . json_encode(['content' => $content]) . "\n\n";
6733 flush();
6734 }
6735 }
6736
6737 return strlen($data);
6738 });
6739
6740 $response = curl_exec($ch);
6741 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
6742
6743 if (curl_errno($ch) || $http_code !== 200) {
6744 $curl_error = curl_error($ch);
6745 curl_close($ch);
6746
6747 // Fallback to regular response
6748 $regular_response = $this->mxchat_generate_response_openai(
6749 $selected_model,
6750 $api_key,
6751 $conversation_history,
6752 $relevant_content
6753 );
6754
6755 // FIXED: Check if regular response returned an error
6756 if (is_array($regular_response) && isset($regular_response['error'])) {
6757 // Send error in SSE format since we're in streaming mode
6758 echo "data: " . json_encode([
6759 'error' => true,
6760 'error_message' => $regular_response['error'],
6761 'error_code' => $regular_response['error_code'] ?? 'api_error',
6762 'text' => $regular_response['error'],
6763 'message' => $regular_response['error']
6764 ]) . "\n\n";
6765 echo "data: [DONE]\n\n";
6766 flush();
6767 return true;
6768 }
6769
6770 $response_data = [
6771 'text' => $regular_response,
6772 'html' => '',
6773 'session_id' => $session_id
6774 ];
6775
6776 if ($testing_data !== null) {
6777 $response_data['testing_data'] = $testing_data;
6778 }
6779
6780 header('Content-Type: application/json');
6781 echo json_encode($response_data);
6782 return true;
6783 }
6784
6785 curl_close($ch);
6786
6787 // Save the complete response to maintain chat persistence
6788 if (!empty($full_response) && !empty($session_id)) {
6789 // Prepare RAG context for streaming response
6790 $rag_context_for_storage = null;
6791 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
6792 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
6793
6794 if ($has_rag_data || $has_action_data) {
6795 $rag_context_for_storage = [];
6796
6797 if ($has_rag_data) {
6798 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
6799 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
6800 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
6801 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
6802 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
6803 }
6804
6805 if ($has_action_data) {
6806 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
6807 }
6808 }
6809 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
6810 }
6811
6812 return true; // Indicate streaming completed successfully
6813
6814 } catch (Exception $e) {
6815 // Fallback to regular response
6816 $regular_response = $this->mxchat_generate_response_openai(
6817 $selected_model,
6818 $api_key,
6819 $conversation_history,
6820 $relevant_content
6821 );
6822
6823 // FIXED: Check if regular response returned an error
6824 if (is_array($regular_response) && isset($regular_response['error'])) {
6825 // Send error in SSE format since we're in streaming mode
6826 echo "data: " . json_encode([
6827 'error' => true,
6828 'error_message' => $regular_response['error'],
6829 'error_code' => $regular_response['error_code'] ?? 'api_error',
6830 'text' => $regular_response['error'],
6831 'message' => $regular_response['error']
6832 ]) . "\n\n";
6833 echo "data: [DONE]\n\n";
6834 flush();
6835 return true;
6836 }
6837
6838 $response_data = [
6839 'text' => $regular_response,
6840 'html' => '',
6841 'session_id' => $session_id
6842 ];
6843
6844 if ($testing_data !== null) {
6845 $response_data['testing_data'] = $testing_data;
6846 }
6847
6848 header('Content-Type: application/json');
6849 echo json_encode($response_data);
6850 return true;
6851 }
6852 }
6853
6854 /**
6855 * Generate response using OpenAI Responses API with web search tool
6856 * This uses the newer Responses API which supports web search functionality
6857 */
6858 private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
6859 try {
6860 $bot_id = $this->get_current_bot_id($session_id);
6861 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
6862
6863 if (!is_array($conversation_history)) {
6864 $conversation_history = array();
6865 }
6866
6867 // Build the input for Responses API
6868 // The Responses API uses a different format - we need to construct the input properly
6869 $input_parts = [];
6870
6871 // Add system instructions as context
6872 $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
6873
6874 // Build conversation as input items for Responses API
6875 foreach ($conversation_history as $message) {
6876 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
6877 $role = $message['role'];
6878 if ($role === 'bot' || $role === 'agent') {
6879 $role = 'assistant';
6880 }
6881 if (!in_array($role, ['assistant', 'user'])) {
6882 $role = 'user';
6883 }
6884 $input_parts[] = [
6885 'type' => 'message',
6886 'role' => $role,
6887 'content' => $message['content']
6888 ];
6889 }
6890 }
6891
6892 // Build request body for Responses API with web search
6893 $request_body = [
6894 'model' => $selected_model,
6895 'input' => $input_parts,
6896 'instructions' => $system_context,
6897 'tools' => [
6898 ['type' => 'web_search']
6899 ],
6900 'stream' => $streaming
6901 ];
6902
6903 // Add reasoning effort for supported models (not for gpt-5 with minimal which doesn't support web search)
6904 // Per OpenAI docs: web search is not supported with gpt-5 minimal reasoning
6905 $is_gpt5_model = strpos($selected_model, 'gpt-5') === 0;
6906 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
6907 // Use 'low' for GPT-5.1, skip for others to avoid 'minimal' which doesn't support web search
6908 if ($selected_model === 'gpt-5.1-2025-11-13') {
6909 $request_body['reasoning'] = ['effort' => 'low'];
6910 }
6911 // For other GPT-5 models, don't set reasoning to allow web search
6912 }
6913
6914 error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
6915
6916 if ($streaming) {
6917 return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
6918 } else {
6919 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
6920 }
6921
6922 } catch (Exception $e) {
6923 error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
6924 return [
6925 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
6926 'error_code' => 'web_search_exception'
6927 ];
6928 }
6929 }
6930
6931 /**
6932 * Handle non-streaming web search response
6933 */
6934 private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
6935 $request_body['stream'] = false;
6936
6937 $response = wp_remote_post('https://api.openai.com/v1/responses', array(
6938 'headers' => array(
6939 'Authorization' => 'Bearer ' . $api_key,
6940 'Content-Type' => 'application/json'
6941 ),
6942 'body' => json_encode($request_body),
6943 'timeout' => 90 // Web search can take longer
6944 ));
6945
6946 if (is_wp_error($response)) {
6947 error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
6948 return [
6949 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
6950 'error_code' => 'web_search_connection_error'
6951 ];
6952 }
6953
6954 $response_code = wp_remote_retrieve_response_code($response);
6955 $response_body = wp_remote_retrieve_body($response);
6956
6957 error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
6958 error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
6959
6960 if ($response_code !== 200) {
6961 $error_data = json_decode($response_body, true);
6962 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
6963 return [
6964 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
6965 'error_code' => 'web_search_api_error'
6966 ];
6967 }
6968
6969 $result = json_decode($response_body, true);
6970
6971 if (json_last_error() !== JSON_ERROR_NONE) {
6972 return [
6973 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
6974 'error_code' => 'web_search_json_error'
6975 ];
6976 }
6977
6978 // Extract the response text and citations from Responses API format
6979 $output_text = '';
6980 $citations = [];
6981
6982 if (isset($result['output'])) {
6983 foreach ($result['output'] as $output_item) {
6984 if ($output_item['type'] === 'message' && isset($output_item['content'])) {
6985 foreach ($output_item['content'] as $content_item) {
6986 if ($content_item['type'] === 'output_text') {
6987 $output_text .= $content_item['text'];
6988
6989 // Extract citations/annotations
6990 if (isset($content_item['annotations'])) {
6991 foreach ($content_item['annotations'] as $annotation) {
6992 if ($annotation['type'] === 'url_citation') {
6993 $citations[] = [
6994 'url' => $annotation['url'],
6995 'title' => $annotation['title'] ?? ''
6996 ];
6997 }
6998 }
6999 }
7000 }
7001 }
7002 }
7003 }
7004 }
7005
7006 // If we have citations, append them to the response
7007 if (!empty($citations)) {
7008 $output_text .= "\n\n**Sources:**\n";
7009 $seen_urls = [];
7010 foreach ($citations as $citation) {
7011 if (!in_array($citation['url'], $seen_urls)) {
7012 $seen_urls[] = $citation['url'];
7013 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7014 $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7015 }
7016 }
7017 }
7018
7019 // Save to transcript
7020 if (!empty($output_text) && !empty($session_id)) {
7021 $this->mxchat_save_chat_message($session_id, 'bot', $output_text);
7022 }
7023
7024 return $output_text;
7025 }
7026
7027 /**
7028 * Handle streaming web search response using Responses API
7029 */
7030 private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
7031 $request_body['stream'] = true;
7032
7033 // Check if we can stream
7034 if (headers_sent() || !function_exists('curl_init')) {
7035 // Fallback to non-streaming
7036 return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7037 }
7038
7039 // Setup streaming headers
7040 $this->setup_streaming_headers();
7041
7042 $ch = curl_init();
7043 curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
7044 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7045 curl_setopt($ch, CURLOPT_POST, true);
7046 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
7047 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7048 'Content-Type: application/json',
7049 'Authorization: Bearer ' . $api_key
7050 ));
7051 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7052 curl_setopt($ch, CURLOPT_TIMEOUT, 120); // Web search can take longer
7053
7054 $full_response = '';
7055 $stream_started = false;
7056 $buffer = '';
7057 $citations = [];
7058
7059 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, $testing_data) {
7060 // Send testing data as first event if available
7061 if (!$stream_started && $testing_data !== null) {
7062 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7063 flush();
7064 $stream_started = true;
7065 }
7066
7067 $buffer .= $data;
7068 $lines = explode("\n", $buffer);
7069 $buffer = array_pop($lines);
7070
7071 foreach ($lines as $line) {
7072 if (trim($line) === '') continue;
7073 if (strpos($line, 'data: ') !== 0) continue;
7074
7075 $json_str = substr($line, 6);
7076
7077 if (trim($json_str) === '[DONE]') {
7078 // Append citations if we have any
7079 if (!empty($citations)) {
7080 $citation_text = "\n\n**Sources:**\n";
7081 $seen_urls = [];
7082 foreach ($citations as $citation) {
7083 if (!in_array($citation['url'], $seen_urls)) {
7084 $seen_urls[] = $citation['url'];
7085 $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
7086 $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
7087 }
7088 }
7089 echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
7090 $full_response .= $citation_text;
7091 flush();
7092 }
7093 echo "data: [DONE]\n\n";
7094 flush();
7095 continue;
7096 }
7097
7098 $json = json_decode(trim($json_str), true);
7099 if (!$json) continue;
7100
7101 // Handle Responses API streaming events
7102 // The format is different from Chat Completions
7103 if (isset($json['type'])) {
7104 switch ($json['type']) {
7105 case 'response.output_text.delta':
7106 // Text content delta
7107 if (isset($json['delta'])) {
7108 $content = $json['delta'];
7109 $full_response .= $content;
7110 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7111 flush();
7112 }
7113 break;
7114
7115 case 'response.output_item.done':
7116 // Check for citations in completed items
7117 if (isset($json['item']['content'])) {
7118 foreach ($json['item']['content'] as $content_item) {
7119 if (isset($content_item['annotations'])) {
7120 foreach ($content_item['annotations'] as $annotation) {
7121 if ($annotation['type'] === 'url_citation') {
7122 $citations[] = [
7123 'url' => $annotation['url'],
7124 'title' => $annotation['title'] ?? ''
7125 ];
7126 }
7127 }
7128 }
7129 }
7130 }
7131 break;
7132 }
7133 }
7134 }
7135
7136 return strlen($data);
7137 });
7138
7139 $response = curl_exec($ch);
7140 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7141
7142 if (curl_errno($ch) || $http_code !== 200) {
7143 $curl_error = curl_error($ch);
7144 curl_close($ch);
7145
7146 error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
7147
7148 // Fallback to non-streaming
7149 $fallback_response = $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
7150
7151 if (is_array($fallback_response) && isset($fallback_response['error'])) {
7152 echo "data: " . json_encode([
7153 'error' => true,
7154 'error_message' => $fallback_response['error'],
7155 'error_code' => $fallback_response['error_code'] ?? 'web_search_error'
7156 ]) . "\n\n";
7157 echo "data: [DONE]\n\n";
7158 flush();
7159 return true;
7160 }
7161
7162 $response_data = [
7163 'text' => $fallback_response,
7164 'html' => '',
7165 'session_id' => $session_id
7166 ];
7167 if ($testing_data !== null) {
7168 $response_data['testing_data'] = $testing_data;
7169 }
7170 header('Content-Type: application/json');
7171 echo json_encode($response_data);
7172 return true;
7173 }
7174
7175 curl_close($ch);
7176
7177 // Save the complete response
7178 if (!empty($full_response) && !empty($session_id)) {
7179 $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
7180 }
7181
7182 return true;
7183 }
7184
7185 private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7186 try {
7187 // Get bot ID from session or request
7188 $bot_id = $this->get_current_bot_id($session_id);
7189
7190 // Get system prompt instructions using centralized function
7191 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7192 // Ensure conversation_history is an array
7193 if (!is_array($conversation_history)) {
7194 $conversation_history = array();
7195 }
7196
7197 // Clean and validate conversation history
7198 foreach ($conversation_history as &$message) {
7199 // Convert bot and agent roles to assistant
7200 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
7201 $message['role'] = 'assistant';
7202 }
7203
7204 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
7205 if (!in_array($message['role'], ['assistant', 'user'])) {
7206 $message['role'] = 'user';
7207 }
7208
7209 // Ensure content field exists
7210 if (!isset($message['content']) || empty($message['content'])) {
7211 $message['content'] = '';
7212 }
7213
7214 // Remove any unsupported fields
7215 $message = array_intersect_key($message, array_flip(['role', 'content']));
7216 }
7217
7218 // Add relevant content as the latest user message
7219 $conversation_history[] = [
7220 'role' => 'user',
7221 'content' => $relevant_content
7222 ];
7223
7224 // Prepare the request body with stream: true
7225 $body = json_encode([
7226 'model' => $selected_model,
7227 'messages' => $conversation_history,
7228 'max_tokens' => 1000,
7229 'temperature' => 0.8,
7230 'system' => $system_prompt_instructions,
7231 'stream' => true
7232 ]);
7233
7234 // Check if we can actually stream (headers not sent, etc.)
7235 if (headers_sent() || !function_exists('curl_init')) {
7236 // Fallback to regular response with testing data
7237 //error_log("MxChat: Streaming not possible, falling back to regular response");
7238 $regular_response = $this->mxchat_generate_response_claude(
7239 $selected_model,
7240 $claude_api_key,
7241 array_slice($conversation_history, 0, -1), // Remove the added content
7242 $relevant_content
7243 );
7244
7245 // Save bot response to transcript
7246 if (!empty($regular_response) && !empty($session_id)) {
7247 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7248 }
7249
7250 // Return as JSON with testing data
7251 $response_data = [
7252 'text' => $regular_response,
7253 'html' => '',
7254 'session_id' => $session_id
7255 ];
7256
7257 if ($testing_data !== null) {
7258 $response_data['testing_data'] = $testing_data;
7259 //error_log("MxChat Testing: Added testing data to Claude fallback response");
7260 }
7261
7262 // Clear any streaming headers and send JSON
7263 if (headers_sent() === false) {
7264 header('Content-Type: application/json');
7265 }
7266 echo json_encode($response_data);
7267 return true; // Indicate we handled the response
7268 }
7269
7270 // Setup streaming headers now that we know we're actually streaming
7271 $this->setup_streaming_headers();
7272
7273 // Use cURL for streaming support
7274 $ch = curl_init();
7275 curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
7276 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7277 curl_setopt($ch, CURLOPT_POST, true);
7278 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7279 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7280 'Content-Type: application/json',
7281 'x-api-key: ' . $claude_api_key,
7282 'anthropic-version: 2023-06-01'
7283 ));
7284 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7285 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7286
7287 $full_response = ''; // Accumulate full response for saving
7288 $stream_started = false;
7289 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7290
7291 // Buffer control for real-time streaming
7292 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7293 // Send testing data as the first event if available
7294 if (!$stream_started && $testing_data !== null) {
7295 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7296 flush();
7297 $stream_started = true;
7298 //error_log("MxChat Testing: Sent testing data in Claude stream");
7299 }
7300
7301 // CRITICAL FIX: Append new data to buffer
7302 $buffer .= $data;
7303
7304 // Process complete lines only
7305 $lines = explode("\n", $buffer);
7306
7307 // CRITICAL FIX: Keep the last incomplete line in the buffer
7308 // The last element might be incomplete, so keep it in buffer
7309 $buffer = array_pop($lines);
7310
7311 foreach ($lines as $line) {
7312 if (trim($line) === '') {
7313 continue;
7314 }
7315
7316 // Claude uses event: and data: format
7317 if (strpos($line, 'event: ') === 0) {
7318 // Store the event type for the next data line
7319 continue;
7320 }
7321
7322 if (strpos($line, 'data: ') === 0) {
7323 $json_str = substr($line, 6); // Remove 'data: ' prefix
7324
7325 $json = json_decode(trim($json_str), true);
7326 if (json_last_error() !== JSON_ERROR_NONE) {
7327 continue;
7328 }
7329
7330 // Handle different event types
7331 if (isset($json['type'])) {
7332 switch ($json['type']) {
7333 case 'content_block_delta':
7334 if (isset($json['delta']['text'])) {
7335 $content = $json['delta']['text'];
7336 $full_response .= $content; // Accumulate
7337 // Send as SSE format compatible with your frontend
7338 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7339 flush();
7340 }
7341 break;
7342
7343 case 'message_stop':
7344 echo "data: [DONE]\n\n";
7345 flush();
7346 break;
7347
7348 case 'error':
7349 echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
7350 flush();
7351 break;
7352 }
7353 }
7354 }
7355 }
7356
7357 return strlen($data);
7358 });
7359
7360 $response = curl_exec($ch);
7361 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7362
7363 if (curl_errno($ch)) {
7364 curl_close($ch);
7365 throw new Exception('cURL Error: ' . curl_error($ch));
7366 }
7367
7368 curl_close($ch);
7369
7370 if ($http_code !== 200) {
7371 // Fallback to regular response
7372 //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
7373 $regular_response = $this->mxchat_generate_response_claude(
7374 $selected_model,
7375 $claude_api_key,
7376 array_slice($conversation_history, 0, -1), // Remove the added content
7377 $relevant_content
7378 );
7379
7380 // FIXED: Check if regular response returned an error
7381 if (is_array($regular_response) && isset($regular_response['error'])) {
7382 // Send error in SSE format since we're in streaming mode
7383 echo "data: " . json_encode([
7384 'error' => true,
7385 'error_message' => $regular_response['error'],
7386 'error_code' => $regular_response['error_code'] ?? 'api_error',
7387 'text' => $regular_response['error'],
7388 'message' => $regular_response['error']
7389 ]) . "\n\n";
7390 echo "data: [DONE]\n\n";
7391 flush();
7392 return true;
7393 }
7394
7395 $response_data = [
7396 'text' => $regular_response,
7397 'html' => '',
7398 'session_id' => $session_id
7399 ];
7400
7401 if ($testing_data !== null) {
7402 $response_data['testing_data'] = $testing_data;
7403 //error_log("MxChat Testing: Added testing data to Claude error fallback");
7404 }
7405
7406 header('Content-Type: application/json');
7407 echo json_encode($response_data);
7408 return true;
7409 }
7410
7411 // Save the complete response to maintain chat persistence
7412 if (!empty($full_response) && !empty($session_id)) {
7413 // Prepare RAG context for streaming response
7414 $rag_context_for_storage = null;
7415 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7416 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7417
7418 if ($has_rag_data || $has_action_data) {
7419 $rag_context_for_storage = [];
7420
7421 if ($has_rag_data) {
7422 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7423 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7424 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7425 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7426 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7427 }
7428
7429 if ($has_action_data) {
7430 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7431 }
7432 }
7433 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7434 }
7435
7436 return true; // Indicate streaming completed successfully
7437
7438 } catch (Exception $e) {
7439 //error_log("MxChat Claude streaming exception: " . $e->getMessage());
7440
7441 // Fallback to regular response on exception
7442 $regular_response = $this->mxchat_generate_response_claude(
7443 $selected_model,
7444 $claude_api_key,
7445 $conversation_history,
7446 $relevant_content
7447 );
7448
7449 // FIXED: Check if regular response returned an error
7450 if (is_array($regular_response) && isset($regular_response['error'])) {
7451 // Send error in SSE format since we're in streaming mode
7452 echo "data: " . json_encode([
7453 'error' => true,
7454 'error_message' => $regular_response['error'],
7455 'error_code' => $regular_response['error_code'] ?? 'api_error',
7456 'text' => $regular_response['error'],
7457 'message' => $regular_response['error']
7458 ]) . "\n\n";
7459 echo "data: [DONE]\n\n";
7460 flush();
7461 return true;
7462 }
7463
7464 $response_data = [
7465 'text' => $regular_response,
7466 'html' => '',
7467 'session_id' => $session_id
7468 ];
7469
7470 if ($testing_data !== null) {
7471 $response_data['testing_data'] = $testing_data;
7472 //error_log("MxChat Testing: Added testing data to Claude exception fallback");
7473 }
7474
7475 header('Content-Type: application/json');
7476 echo json_encode($response_data);
7477 return true;
7478 }
7479 }
7480 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7481 try {
7482 // Get bot ID from session or request
7483 $bot_id = $this->get_current_bot_id($session_id);
7484
7485 // Get system prompt instructions using centralized function
7486 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7487
7488 // Ensure conversation_history is an array
7489 if (!is_array($conversation_history)) {
7490 $conversation_history = array();
7491 }
7492
7493 // Format conversation history for X.AI (same as OpenAI format)
7494 $formatted_conversation = array();
7495
7496 $formatted_conversation[] = array(
7497 'role' => 'system',
7498 'content' => $system_prompt_instructions . " " . $relevant_content
7499 );
7500
7501 foreach ($conversation_history as $message) {
7502 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7503 $role = $message['role'];
7504 if ($role === 'bot' || $role === 'agent') {
7505 $role = 'assistant';
7506 }
7507 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7508 $role = 'user';
7509 }
7510 $formatted_conversation[] = array(
7511 'role' => $role,
7512 'content' => $message['content']
7513 );
7514 }
7515 }
7516
7517 // Check if we can actually stream
7518 if (headers_sent() || !function_exists('curl_init')) {
7519 // Fallback to regular response with testing data
7520 //error_log("MxChat: X.AI streaming not possible, falling back to regular response");
7521 $regular_response = $this->mxchat_generate_response_xai(
7522 $selected_model,
7523 $xai_api_key,
7524 $conversation_history,
7525 $relevant_content
7526 );
7527
7528 // Save bot response to transcript
7529 if (!empty($regular_response) && !empty($session_id)) {
7530 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7531 }
7532
7533 $response_data = [
7534 'text' => $regular_response,
7535 'html' => '',
7536 'session_id' => $session_id
7537 ];
7538
7539 if ($testing_data !== null) {
7540 $response_data['testing_data'] = $testing_data;
7541 //error_log("MxChat Testing: Added testing data to X.AI fallback response");
7542 }
7543
7544 header('Content-Type: application/json');
7545 echo json_encode($response_data);
7546 return true;
7547 }
7548
7549 // Prepare the request body with stream: true
7550 $body = json_encode([
7551 'model' => $selected_model,
7552 'messages' => $formatted_conversation,
7553 'temperature' => 0.8,
7554 'stream' => true
7555 ]);
7556
7557 // Setup streaming headers now that we know we're actually streaming
7558 $this->setup_streaming_headers();
7559
7560 // Use cURL for streaming support
7561 $ch = curl_init();
7562 curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
7563 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7564 curl_setopt($ch, CURLOPT_POST, true);
7565 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7566 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7567 'Content-Type: application/json',
7568 'Authorization: Bearer ' . $xai_api_key
7569 ));
7570 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7571 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7572
7573 $full_response = ''; // Accumulate full response for saving
7574 $stream_started = false;
7575 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7576
7577 // Buffer control for real-time streaming
7578 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7579 // Send testing data as the first event if available
7580 if (!$stream_started && $testing_data !== null) {
7581 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7582 flush();
7583 $stream_started = true;
7584 //error_log("MxChat Testing: Sent testing data in X.AI stream");
7585 }
7586
7587 // CRITICAL FIX: Append new data to buffer
7588 $buffer .= $data;
7589
7590 // Process complete lines only
7591 $lines = explode("\n", $buffer);
7592
7593 // CRITICAL FIX: Keep the last incomplete line in the buffer
7594 // The last element might be incomplete, so keep it in buffer
7595 $buffer = array_pop($lines);
7596
7597 foreach ($lines as $line) {
7598 // Skip empty lines
7599 if (trim($line) === '') {
7600 continue;
7601 }
7602
7603 // Only process lines that start with "data: "
7604 if (strpos($line, 'data: ') !== 0) {
7605 continue;
7606 }
7607
7608 $json_str = substr($line, 6); // Remove 'data: ' prefix
7609
7610 if (trim($json_str) === '[DONE]') {
7611 echo "data: [DONE]\n\n";
7612 flush();
7613 continue;
7614 }
7615
7616 // Try to decode JSON
7617 $json = json_decode(trim($json_str), true);
7618 if ($json && isset($json['choices'][0]['delta']['content'])) {
7619 $content = $json['choices'][0]['delta']['content'];
7620 $full_response .= $content; // Accumulate
7621 // Send as SSE format
7622 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7623 flush();
7624 }
7625 }
7626
7627 return strlen($data);
7628 });
7629
7630 $response = curl_exec($ch);
7631 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7632
7633 if (curl_errno($ch) || $http_code !== 200) {
7634 curl_close($ch);
7635
7636 // Fallback to regular response
7637 //error_log("MxChat: X.AI streaming failed, falling back");
7638 $regular_response = $this->mxchat_generate_response_xai(
7639 $selected_model,
7640 $xai_api_key,
7641 $conversation_history,
7642 $relevant_content
7643 );
7644
7645 $response_data = [
7646 'text' => $regular_response,
7647 'html' => '',
7648 'session_id' => $session_id
7649 ];
7650
7651 if ($testing_data !== null) {
7652 $response_data['testing_data'] = $testing_data;
7653 //error_log("MxChat Testing: Added testing data to X.AI error fallback");
7654 }
7655
7656 header('Content-Type: application/json');
7657 echo json_encode($response_data);
7658 return true;
7659 }
7660
7661 curl_close($ch);
7662
7663 // Save the complete response to maintain chat persistence
7664 if (!empty($full_response) && !empty($session_id)) {
7665 // Prepare RAG context for streaming response
7666 $rag_context_for_storage = null;
7667 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7668 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7669
7670 if ($has_rag_data || $has_action_data) {
7671 $rag_context_for_storage = [];
7672
7673 if ($has_rag_data) {
7674 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7675 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7676 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7677 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7678 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7679 }
7680
7681 if ($has_action_data) {
7682 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7683 }
7684 }
7685 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7686 }
7687
7688 return true; // Indicate streaming completed successfully
7689
7690 } catch (Exception $e) {
7691 //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
7692
7693 // Fallback to regular response
7694 $regular_response = $this->mxchat_generate_response_xai(
7695 $selected_model,
7696 $xai_api_key,
7697 $conversation_history,
7698 $relevant_content
7699 );
7700
7701 $response_data = [
7702 'text' => $regular_response,
7703 'html' => '',
7704 'session_id' => $session_id
7705 ];
7706
7707 if ($testing_data !== null) {
7708 $response_data['testing_data'] = $testing_data;
7709 //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
7710 }
7711
7712 header('Content-Type: application/json');
7713 echo json_encode($response_data);
7714 return true;
7715 }
7716 }
7717 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
7718 try {
7719 // Get bot ID from session or request
7720 $bot_id = $this->get_current_bot_id($session_id);
7721
7722 // Get system prompt instructions using centralized function
7723 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7724
7725 // Ensure conversation_history is an array
7726 if (!is_array($conversation_history)) {
7727 $conversation_history = array();
7728 }
7729
7730 // Format conversation history for DeepSeek
7731 $formatted_conversation = array();
7732
7733 $formatted_conversation[] = array(
7734 'role' => 'system',
7735 'content' => $system_prompt_instructions . " " . $relevant_content
7736 );
7737
7738 foreach ($conversation_history as $message) {
7739 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7740 $role = $message['role'];
7741 if ($role === 'bot' || $role === 'agent') {
7742 $role = 'assistant';
7743 }
7744 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
7745 $role = 'user';
7746 }
7747 $formatted_conversation[] = array(
7748 'role' => $role,
7749 'content' => $message['content']
7750 );
7751 }
7752 }
7753
7754 // Check if we can actually stream
7755 if (headers_sent() || !function_exists('curl_init')) {
7756 // Fallback to regular response with testing data
7757 //error_log("MxChat: DeepSeek streaming not possible, falling back to regular response");
7758 $regular_response = $this->mxchat_generate_response_deepseek(
7759 $selected_model,
7760 $deepseek_api_key,
7761 $conversation_history,
7762 $relevant_content
7763 );
7764
7765 // Save bot response to transcript
7766 if (!empty($regular_response) && !empty($session_id)) {
7767 $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
7768 }
7769
7770 $response_data = [
7771 'text' => $regular_response,
7772 'html' => '',
7773 'session_id' => $session_id
7774 ];
7775
7776 if ($testing_data !== null) {
7777 $response_data['testing_data'] = $testing_data;
7778 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
7779 }
7780
7781 header('Content-Type: application/json');
7782 echo json_encode($response_data);
7783 return true;
7784 }
7785
7786 // Prepare the request body with stream: true
7787 $body = json_encode([
7788 'model' => $selected_model,
7789 'messages' => $formatted_conversation,
7790 'temperature' => 0.8,
7791 'stream' => true
7792 ]);
7793
7794 // Setup streaming headers now that we know we're actually streaming
7795 $this->setup_streaming_headers();
7796
7797 // Use cURL for streaming support
7798 $ch = curl_init();
7799 curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
7800 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
7801 curl_setopt($ch, CURLOPT_POST, true);
7802 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
7803 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
7804 'Content-Type: application/json',
7805 'Authorization: Bearer ' . $deepseek_api_key
7806 ));
7807 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
7808 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
7809
7810 $full_response = ''; // Accumulate full response for saving
7811 $stream_started = false;
7812 $buffer = ''; // CRITICAL: Add persistent buffer for incomplete chunks
7813
7814 // Buffer control for real-time streaming
7815 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, $testing_data) {
7816 // Send testing data as the first event if available
7817 if (!$stream_started && $testing_data !== null) {
7818 echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
7819 flush();
7820 $stream_started = true;
7821 //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
7822 }
7823
7824 // CRITICAL FIX: Append new data to buffer
7825 $buffer .= $data;
7826
7827 // Process complete lines only
7828 $lines = explode("\n", $buffer);
7829
7830 // CRITICAL FIX: Keep the last incomplete line in the buffer
7831 // The last element might be incomplete, so keep it in buffer
7832 $buffer = array_pop($lines);
7833
7834 foreach ($lines as $line) {
7835 // Skip empty lines
7836 if (trim($line) === '') {
7837 continue;
7838 }
7839
7840 // Only process lines that start with "data: "
7841 if (strpos($line, 'data: ') !== 0) {
7842 continue;
7843 }
7844
7845 $json_str = substr($line, 6); // Remove 'data: ' prefix
7846
7847 if (trim($json_str) === '[DONE]') {
7848 echo "data: [DONE]\n\n";
7849 flush();
7850 continue;
7851 }
7852
7853 // Try to decode JSON
7854 $json = json_decode(trim($json_str), true);
7855 if ($json && isset($json['choices'][0]['delta']['content'])) {
7856 $content = $json['choices'][0]['delta']['content'];
7857 $full_response .= $content; // Accumulate the full response
7858
7859 // Send as SSE format
7860 echo "data: " . json_encode(['content' => $content]) . "\n\n";
7861 flush();
7862 }
7863 }
7864
7865 return strlen($data);
7866 });
7867
7868 $response = curl_exec($ch);
7869 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
7870
7871 if (curl_errno($ch) || $http_code !== 200) {
7872 $curl_error = curl_error($ch);
7873 curl_close($ch);
7874
7875 // Log the specific error for debugging
7876 //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
7877
7878 // Fallback to regular response
7879 $regular_response = $this->mxchat_generate_response_deepseek(
7880 $selected_model,
7881 $deepseek_api_key,
7882 $conversation_history,
7883 $relevant_content
7884 );
7885
7886 // Handle error response from regular function
7887 if (is_array($regular_response) && isset($regular_response['error'])) {
7888 if ($testing_data !== null) {
7889 $regular_response['testing_data'] = $testing_data;
7890 }
7891 header('Content-Type: application/json');
7892 echo json_encode($regular_response);
7893 return true;
7894 }
7895
7896 $response_data = [
7897 'text' => $regular_response,
7898 'html' => '',
7899 'session_id' => $session_id
7900 ];
7901
7902 if ($testing_data !== null) {
7903 $response_data['testing_data'] = $testing_data;
7904 //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
7905 }
7906
7907 header('Content-Type: application/json');
7908 echo json_encode($response_data);
7909 return true;
7910 }
7911
7912 curl_close($ch);
7913
7914 // Save the complete response to maintain chat persistence
7915 if (!empty($full_response) && !empty($session_id)) {
7916 // Prepare RAG context for streaming response
7917 $rag_context_for_storage = null;
7918 $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
7919 $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
7920
7921 if ($has_rag_data || $has_action_data) {
7922 $rag_context_for_storage = [];
7923
7924 if ($has_rag_data) {
7925 $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
7926 $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
7927 $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
7928 $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
7929 $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
7930 }
7931
7932 if ($has_action_data) {
7933 $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
7934 }
7935 }
7936 $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
7937 }
7938
7939 return true; // Indicate streaming completed successfully
7940
7941 } catch (Exception $e) {
7942 //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
7943
7944 // Fallback to regular response
7945 $regular_response = $this->mxchat_generate_response_deepseek(
7946 $selected_model,
7947 $deepseek_api_key,
7948 $conversation_history,
7949 $relevant_content
7950 );
7951
7952 // Handle error response from regular function
7953 if (is_array($regular_response) && isset($regular_response['error'])) {
7954 if ($testing_data !== null) {
7955 $regular_response['testing_data'] = $testing_data;
7956 }
7957 header('Content-Type: application/json');
7958 echo json_encode($regular_response);
7959 return true;
7960 }
7961
7962 $response_data = [
7963 'text' => $regular_response,
7964 'html' => '',
7965 'session_id' => $session_id
7966 ];
7967
7968 if ($testing_data !== null) {
7969 $response_data['testing_data'] = $testing_data;
7970 //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
7971 }
7972
7973 header('Content-Type: application/json');
7974 echo json_encode($response_data);
7975 return true;
7976 }
7977 }
7978
7979
7980 private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content) {
7981 try {
7982 if (!is_array($conversation_history)) {
7983 $conversation_history = array();
7984 }
7985
7986 $bot_id = $this->get_current_bot_id('');
7987 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
7988
7989 $formatted_conversation = array();
7990
7991 $formatted_conversation[] = array(
7992 'role' => 'system',
7993 'content' => $system_prompt_instructions . " " . $relevant_content
7994 );
7995
7996 foreach ($conversation_history as $message) {
7997 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
7998 $role = $message['role'];
7999
8000 if ($role === 'bot' || $role === 'agent') {
8001 $role = 'assistant';
8002 }
8003 if (!in_array($role, ['system', 'assistant', 'user'])) {
8004 $role = 'user';
8005 }
8006
8007 $formatted_conversation[] = array(
8008 'role' => $role,
8009 'content' => $message['content']
8010 );
8011 }
8012 }
8013
8014 $body = json_encode([
8015 'model' => $selected_model,
8016 'messages' => $formatted_conversation,
8017 'temperature' => 1,
8018 ]);
8019
8020 $args = [
8021 'body' => $body,
8022 'headers' => [
8023 'Content-Type' => 'application/json',
8024 'Authorization' => 'Bearer ' . $openrouter_api_key,
8025 'HTTP-Referer' => home_url(),
8026 'X-Title' => get_bloginfo('name'),
8027 ],
8028 'timeout' => 60,
8029 'redirection' => 5,
8030 'blocking' => true,
8031 'httpversion' => '1.0',
8032 'sslverify' => true,
8033 ];
8034
8035 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', $args);
8036
8037 if (is_wp_error($response)) {
8038 $error_message = $response->get_error_message();
8039 return [
8040 'error' => esc_html__('Connection error when contacting OpenRouter: ', 'mxchat') . esc_html($error_message),
8041 'error_code' => 'openrouter_connection_error',
8042 'provider' => 'openrouter'
8043 ];
8044 }
8045
8046 $status_code = wp_remote_retrieve_response_code($response);
8047 if ($status_code !== 200) {
8048 $response_body = wp_remote_retrieve_body($response);
8049 $decoded_response = json_decode($response_body, true);
8050
8051 $error_message = isset($decoded_response['error']['message'])
8052 ? $decoded_response['error']['message']
8053 : 'HTTP Error ' . $status_code;
8054
8055 return [
8056 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
8057 'error_code' => 'openrouter_api_error',
8058 'provider' => 'openrouter',
8059 'status_code' => $status_code
8060 ];
8061 }
8062
8063 $response_body = wp_remote_retrieve_body($response);
8064 $decoded_response = json_decode($response_body, true);
8065
8066 if (isset($decoded_response['choices'][0]['message']['content'])) {
8067 return trim($decoded_response['choices'][0]['message']['content']);
8068 } else {
8069 return [
8070 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
8071 'error_code' => 'openrouter_response_format_error',
8072 'provider' => 'openrouter'
8073 ];
8074 }
8075 } catch (Exception $e) {
8076 return [
8077 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
8078 'error_code' => 'openrouter_exception',
8079 'provider' => 'openrouter'
8080 ];
8081 }
8082 }
8083 private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
8084
8085 // Get bot ID from session or request
8086 $bot_id = $this->get_current_bot_id($session_id);
8087
8088 // Get system prompt instructions using centralized function
8089 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8090
8091 // Clean and validate conversation history
8092 foreach ($conversation_history as &$message) {
8093 // Convert bot and agent roles to assistant
8094 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
8095 $message['role'] = 'assistant';
8096 }
8097
8098 // Remove unsupported roles - Claude only supports 'assistant' and 'user'
8099 if (!in_array($message['role'], ['assistant', 'user'])) {
8100 $message['role'] = 'user';
8101 }
8102
8103 // Ensure content field exists
8104 if (!isset($message['content']) || empty($message['content'])) {
8105 $message['content'] = '';
8106 }
8107
8108 // Remove any unsupported fields
8109 $message = array_intersect_key($message, array_flip(['role', 'content']));
8110 }
8111
8112 // Add relevant content as the latest user message
8113 $conversation_history[] = [
8114 'role' => 'user',
8115 'content' => $relevant_content
8116 ];
8117
8118 // Build request body
8119 $body = json_encode([
8120 'model' => $selected_model,
8121 'max_tokens' => 1000,
8122 'temperature' => 0.8,
8123 'messages' => $conversation_history,
8124 'system' => $system_prompt_instructions
8125 ]);
8126
8127 // Set up API request
8128 $args = [
8129 'body' => $body,
8130 'headers' => [
8131 'Content-Type' => 'application/json',
8132 'x-api-key' => $claude_api_key,
8133 'anthropic-version' => '2023-06-01'
8134 ],
8135 'timeout' => 60,
8136 'redirection' => 5,
8137 'blocking' => true,
8138 'httpversion' => '1.0',
8139 'sslverify' => true,
8140 ];
8141
8142 // Make API request
8143 $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
8144
8145 // Check for WordPress errors
8146 if (is_wp_error($response)) {
8147 //error_log("Claude API request error: " . $response->get_error_message());
8148 return "Sorry, there was an error connecting to the API.";
8149 }
8150
8151 // Check HTTP response code
8152 $http_code = wp_remote_retrieve_response_code($response);
8153 if ($http_code !== 200) {
8154 $error_body = wp_remote_retrieve_body($response);
8155 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
8156
8157 // Try to extract error message from response
8158 $error_data = json_decode($error_body, true);
8159 $error_message = isset($error_data['error']['message']) ?
8160 $error_data['error']['message'] :
8161 "HTTP error " . $http_code;
8162
8163 return "Sorry, the API returned an error: " . $error_message;
8164 }
8165
8166 // Parse response
8167 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8168
8169 // Check for JSON decode errors
8170 if (json_last_error() !== JSON_ERROR_NONE) {
8171 //error_log("Claude API JSON decode error: " . json_last_error_msg());
8172 return "Sorry, there was an error processing the API response.";
8173 }
8174
8175 // Extract and validate response content
8176 if (isset($response_body['content']) &&
8177 is_array($response_body['content']) &&
8178 !empty($response_body['content']) &&
8179 isset($response_body['content'][0]['text'])) {
8180 return trim($response_body['content'][0]['text']);
8181 }
8182
8183 // Log unexpected response format
8184 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
8185 return "Sorry, I received an unexpected response format from the API.";
8186 }
8187 private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
8188 try {
8189 // Ensure conversation_history is an array
8190 if (!is_array($conversation_history)) {
8191 $conversation_history = array();
8192 }
8193
8194 // Get bot ID from session or request
8195 $bot_id = $this->get_current_bot_id('');
8196
8197 // Get system prompt instructions using centralized function
8198 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8199
8200 // Create a new array for the formatted conversation
8201 $formatted_conversation = array();
8202
8203 // Add system message first
8204 $formatted_conversation[] = array(
8205 'role' => 'system',
8206 'content' => $system_prompt_instructions . " " . $relevant_content
8207 );
8208
8209 // Add the rest of the conversation history
8210 foreach ($conversation_history as $message) {
8211 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8212 $role = $message['role'];
8213
8214 // Convert roles to supported format
8215 if ($role === 'bot' || $role === 'agent') {
8216 $role = 'assistant';
8217 }
8218 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8219 $role = 'user';
8220 }
8221
8222 $formatted_conversation[] = array(
8223 'role' => $role,
8224 'content' => $message['content']
8225 );
8226 }
8227 }
8228
8229 // Check if this is a GPT-5 model (supports reasoning_effort parameter)
8230 $is_gpt5_model = (
8231 strpos($selected_model, 'gpt-5') === 0 ||
8232 $selected_model === 'gpt-5.2' ||
8233 $selected_model === 'gpt-5.1-2025-11-13' ||
8234 $selected_model === 'gpt-5' ||
8235 $selected_model === 'gpt-5-mini' ||
8236 $selected_model === 'gpt-5-nano'
8237 );
8238
8239 // Build request body with optimal settings for fast responses
8240 $request_body = [
8241 'model' => $selected_model,
8242 'messages' => $formatted_conversation,
8243 'temperature' => 1,
8244 'stream' => false
8245 ];
8246
8247 // Add reasoning_effort only for GPT-5 models (not GPT-5.2 which doesn't support it)
8248 if ($is_gpt5_model && $selected_model !== 'gpt-5.2') {
8249 // GPT-5.1 uses 'low' instead of 'minimal'
8250 if ($selected_model === 'gpt-5.1-2025-11-13') {
8251 $request_body['reasoning_effort'] = 'low';
8252 } else {
8253 $request_body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
8254 }
8255 }
8256
8257 $body = json_encode($request_body);
8258
8259 $args = [
8260 'body' => $body,
8261 'headers' => [
8262 'Content-Type' => 'application/json',
8263 'Authorization' => 'Bearer ' . $api_key,
8264 ],
8265 'timeout' => 60,
8266 'redirection' => 5,
8267 'blocking' => true,
8268 'httpversion' => '1.0',
8269 'sslverify' => true,
8270 ];
8271
8272 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
8273
8274 if (is_wp_error($response)) {
8275 $error_message = $response->get_error_message();
8276 return [
8277 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
8278 'error_code' => 'openai_connection_error',
8279 'provider' => 'openai'
8280 ];
8281 }
8282
8283 $status_code = wp_remote_retrieve_response_code($response);
8284 if ($status_code !== 200) {
8285 $response_body = wp_remote_retrieve_body($response);
8286 $decoded_response = json_decode($response_body, true);
8287
8288 $error_message = isset($decoded_response['error']['message'])
8289 ? $decoded_response['error']['message']
8290 : 'HTTP Error ' . $status_code;
8291
8292 $error_type = isset($decoded_response['error']['type'])
8293 ? $decoded_response['error']['type']
8294 : 'unknown';
8295
8296 // Handle specific error types
8297 switch ($error_type) {
8298 case 'invalid_request_error':
8299 if (strpos($error_message, 'API key') !== false) {
8300 return [
8301 'error' => esc_html__('Invalid OpenAI API key. Please check your API key configuration.', 'mxchat'),
8302 'error_code' => 'openai_invalid_api_key',
8303 'provider' => 'openai'
8304 ];
8305 }
8306 break;
8307
8308 case 'authentication_error':
8309 return [
8310 'error' => esc_html__('Authentication failed with OpenAI. Please check your API key.', 'mxchat'),
8311 'error_code' => 'openai_auth_error',
8312 'provider' => 'openai'
8313 ];
8314
8315 case 'rate_limit_exceeded':
8316 return [
8317 'error' => esc_html__('OpenAI rate limit exceeded. Please try again later.', 'mxchat'),
8318 'error_code' => 'openai_rate_limit',
8319 'provider' => 'openai'
8320 ];
8321
8322 case 'quota_exceeded':
8323 return [
8324 'error' => esc_html__('OpenAI API quota exceeded. Please check your billing details.', 'mxchat'),
8325 'error_code' => 'openai_quota_exceeded',
8326 'provider' => 'openai'
8327 ];
8328 }
8329
8330 // Generic error fallback
8331 return [
8332 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
8333 'error_code' => 'openai_api_error',
8334 'provider' => 'openai',
8335 'status_code' => $status_code
8336 ];
8337 }
8338
8339 $response_body = wp_remote_retrieve_body($response);
8340 $decoded_response = json_decode($response_body, true);
8341
8342 if (isset($decoded_response['choices'][0]['message']['content'])) {
8343 return trim($decoded_response['choices'][0]['message']['content']);
8344 } else {
8345 return [
8346 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
8347 'error_code' => 'openai_response_format_error',
8348 'provider' => 'openai'
8349 ];
8350 }
8351 } catch (Exception $e) {
8352 return [
8353 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
8354 'error_code' => 'openai_exception',
8355 'provider' => 'openai'
8356 ];
8357 }
8358 }
8359
8360 private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
8361 try {
8362 // Get bot ID from session or request
8363 $bot_id = $this->get_current_bot_id($session_id);
8364
8365 // Get system prompt instructions using centralized function
8366 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8367
8368 // Add system prompt to relevant content
8369 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8370
8371 // Prepend system instructions to the conversation history
8372 array_unshift($conversation_history, [
8373 'role' => 'system',
8374 'content' => "Here are your instructions: " . $content_with_instructions
8375 ]);
8376
8377 // Ensure consistency: Replace 'bot' and 'agent' roles with supported values
8378 foreach ($conversation_history as &$message) {
8379 if ($message['role'] === 'bot') {
8380 $message['role'] = 'assistant';
8381 } elseif ($message['role'] === 'agent') {
8382 // Tag the message as coming from a live agent
8383 $message['role'] = 'assistant';
8384 if (!isset($message['metadata'])) {
8385 $message['metadata'] = ['source' => 'live_agent'];
8386 }
8387 }
8388
8389 // Ensure all roles are valid
8390 if (!in_array($message['role'], ['system', 'assistant', 'user', 'function', 'tool'])) {
8391 $message['role'] = 'user'; // Default to 'user'
8392 }
8393 }
8394
8395 // Build the request body
8396 $body = json_encode([
8397 'model' => $selected_model,
8398 'messages' => $conversation_history,
8399 'temperature' => 0.8,
8400 'stream' => false
8401 ]);
8402
8403 // Set up the API request
8404 $args = [
8405 'body' => $body,
8406 'headers' => [
8407 'Content-Type' => 'application/json',
8408 'Authorization' => 'Bearer ' . $xai_api_key,
8409 ],
8410 'timeout' => 60,
8411 'redirection' => 5,
8412 'blocking' => true,
8413 'httpversion' => '1.0',
8414 'sslverify' => true,
8415 ];
8416
8417 // Make the API request
8418 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
8419
8420 // Process the response
8421 if (is_wp_error($response)) {
8422 $error_message = $response->get_error_message();
8423 //error_log('X.AI API Error: ' . $error_message);
8424 return [
8425 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
8426 'error_code' => 'xai_connection_error',
8427 'provider' => 'xai'
8428 ];
8429 }
8430
8431 $status_code = wp_remote_retrieve_response_code($response);
8432 if ($status_code !== 200) {
8433 $response_body = wp_remote_retrieve_body($response);
8434 $decoded_response = json_decode($response_body, true);
8435
8436 // Log the full response for debugging
8437 //error_log('X.AI Error Response: ' . print_r($decoded_response, true));
8438
8439 // Extract error message from X.AI's specific format
8440 $error_message = '';
8441
8442 // Check for direct error string (as seen in your logs)
8443 if (isset($decoded_response['error']) && is_string($decoded_response['error'])) {
8444 $error_message = $decoded_response['error'];
8445 }
8446 // Check for nested error object (OpenAI style)
8447 elseif (isset($decoded_response['error']['message'])) {
8448 $error_message = $decoded_response['error']['message'];
8449 }
8450 // Check for top-level message
8451 elseif (isset($decoded_response['message'])) {
8452 $error_message = $decoded_response['message'];
8453 }
8454 // Fallback
8455 else {
8456 $error_message = 'HTTP Error ' . $status_code;
8457 }
8458
8459 //error_log('X.AI API HTTP Error: ' . $status_code . ' - ' . $error_message);
8460
8461 // Check for API key errors using string matching
8462 if (stripos($error_message, 'api key') !== false ||
8463 stripos($error_message, 'incorrect api key') !== false ||
8464 stripos($error_message, 'invalid api key') !== false) {
8465 return [
8466 'error' => esc_html__('Invalid X.AI API key. Please check your API key configuration.', 'mxchat'),
8467 'error_code' => 'xai_invalid_api_key',
8468 'provider' => 'xai'
8469 ];
8470 }
8471
8472 // Authentication errors
8473 if ($status_code === 401 || $status_code === 403 ||
8474 stripos($error_message, 'auth') !== false) {
8475 return [
8476 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
8477 'error_code' => 'xai_auth_error',
8478 'provider' => 'xai'
8479 ];
8480 }
8481
8482 // Model errors
8483 if (stripos($error_message, 'model') !== false) {
8484 return [
8485 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
8486 'error_code' => 'xai_invalid_model',
8487 'provider' => 'xai'
8488 ];
8489 }
8490
8491 // Rate limit errors
8492 if ($status_code === 429 ||
8493 stripos($error_message, 'rate') !== false ||
8494 stripos($error_message, 'limit') !== false) {
8495 return [
8496 'error' => esc_html__('X.AI rate limit exceeded. Please try again later.', 'mxchat'),
8497 'error_code' => 'xai_rate_limit',
8498 'provider' => 'xai'
8499 ];
8500 }
8501
8502 // Quota errors
8503 if (stripos($error_message, 'quota') !== false ||
8504 stripos($error_message, 'billing') !== false) {
8505 return [
8506 'error' => esc_html__('X.AI API quota exceeded. Please check your billing details.', 'mxchat'),
8507 'error_code' => 'xai_quota_exceeded',
8508 'provider' => 'xai'
8509 ];
8510 }
8511
8512 // Server errors
8513 if ($status_code >= 500) {
8514 return [
8515 'error' => esc_html__('X.AI service is currently unavailable. Please try again later.', 'mxchat'),
8516 'error_code' => 'xai_service_unavailable',
8517 'provider' => 'xai'
8518 ];
8519 }
8520
8521 // Generic error fallback with the actual error message
8522 return [
8523 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
8524 'error_code' => 'xai_api_error',
8525 'provider' => 'xai',
8526 'status_code' => $status_code
8527 ];
8528 }
8529
8530 $response_body = wp_remote_retrieve_body($response);
8531 $decoded_response = json_decode($response_body, true);
8532
8533 if (isset($decoded_response['choices'][0]['message']['content'])) {
8534 return trim($decoded_response['choices'][0]['message']['content']);
8535 } else {
8536 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
8537 return [
8538 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
8539 'error_code' => 'xai_response_format_error',
8540 'provider' => 'xai'
8541 ];
8542 }
8543 } catch (Exception $e) {
8544 //error_log('X.AI Exception: ' . $e->getMessage());
8545 return [
8546 'error' => esc_html__('System error when processing X.AI request: ', 'mxchat') . esc_html($e->getMessage()),
8547 'error_code' => 'xai_exception',
8548 'provider' => 'xai'
8549 ];
8550 }
8551
8552
8553 }
8554 private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
8555 try {
8556 // Ensure conversation_history is an array
8557 if (!is_array($conversation_history)) {
8558 $conversation_history = array();
8559 }
8560
8561 // Get bot ID from session or request
8562 $bot_id = $this->get_current_bot_id($session_id);
8563
8564 // Get system prompt instructions using centralized function
8565 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8566
8567 // Create a new array for the formatted conversation
8568 $formatted_conversation = array();
8569
8570 // Add system message first
8571 $formatted_conversation[] = array(
8572 'role' => 'system',
8573 'content' => $system_prompt_instructions . " " . $relevant_content
8574 );
8575
8576 // Add the rest of the conversation history
8577 foreach ($conversation_history as $message) {
8578 if (is_array($message) && isset($message['role']) && isset($message['content'])) {
8579 $role = $message['role'];
8580
8581 // Convert roles to supported format
8582 if ($role === 'bot' || $role === 'agent') {
8583 $role = 'assistant';
8584 }
8585 if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8586 $role = 'user';
8587 }
8588
8589 $formatted_conversation[] = array(
8590 'role' => $role,
8591 'content' => $message['content']
8592 );
8593 }
8594 }
8595
8596 $body = json_encode([
8597 'model' => $selected_model,
8598 'messages' => $formatted_conversation,
8599 'temperature' => 0.8,
8600 'stream' => false
8601 ]);
8602
8603 $args = [
8604 'body' => $body,
8605 'headers' => [
8606 'Content-Type' => 'application/json',
8607 'Authorization' => 'Bearer ' . $deepseek_api_key,
8608 ],
8609 'timeout' => 60,
8610 'redirection' => 5,
8611 'blocking' => true,
8612 'httpversion' => '1.0',
8613 'sslverify' => true,
8614 ];
8615
8616 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
8617
8618 if (is_wp_error($response)) {
8619 $error_message = $response->get_error_message();
8620 //error_log('DeepSeek API Error: ' . $error_message);
8621 return [
8622 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
8623 'error_code' => 'deepseek_connection_error',
8624 'provider' => 'deepseek'
8625 ];
8626 }
8627
8628 $status_code = wp_remote_retrieve_response_code($response);
8629 if ($status_code !== 200) {
8630 $response_body = wp_remote_retrieve_body($response);
8631 $decoded_response = json_decode($response_body, true);
8632
8633 $error_message = isset($decoded_response['error']['message'])
8634 ? $decoded_response['error']['message']
8635 : 'HTTP Error ' . $status_code;
8636
8637 $error_type = isset($decoded_response['error']['type'])
8638 ? $decoded_response['error']['type']
8639 : 'unknown';
8640
8641 //error_log('DeepSeek API HTTP Error: ' . $status_code . ' - ' . $error_message);
8642
8643 // Handle specific error types
8644 switch ($status_code) {
8645 case 401:
8646 return [
8647 'error' => esc_html__('Authentication failed with DeepSeek. Please check your API key.', 'mxchat'),
8648 'error_code' => 'deepseek_auth_error',
8649 'provider' => 'deepseek'
8650 ];
8651
8652 case 400:
8653 if (strpos($error_message, 'API key') !== false) {
8654 return [
8655 'error' => esc_html__('Invalid DeepSeek API key. Please check your API key configuration.', 'mxchat'),
8656 'error_code' => 'deepseek_invalid_api_key',
8657 'provider' => 'deepseek'
8658 ];
8659 }
8660 break;
8661
8662 case 429:
8663 if (strpos($error_message, 'quota') !== false) {
8664 return [
8665 'error' => esc_html__('DeepSeek API quota exceeded. Please check your billing details.', 'mxchat'),
8666 'error_code' => 'deepseek_quota_exceeded',
8667 'provider' => 'deepseek'
8668 ];
8669 } else {
8670 return [
8671 'error' => esc_html__('DeepSeek rate limit exceeded. Please try again later.', 'mxchat'),
8672 'error_code' => 'deepseek_rate_limit',
8673 'provider' => 'deepseek'
8674 ];
8675 }
8676
8677 case 500:
8678 case 502:
8679 case 503:
8680 case 504:
8681 return [
8682 'error' => esc_html__('DeepSeek service is currently unavailable. Please try again later.', 'mxchat'),
8683 'error_code' => 'deepseek_service_unavailable',
8684 'provider' => 'deepseek'
8685 ];
8686 }
8687
8688 // Generic error fallback
8689 return [
8690 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
8691 'error_code' => 'deepseek_api_error',
8692 'provider' => 'deepseek',
8693 'status_code' => $status_code
8694 ];
8695 }
8696
8697 $response_body = wp_remote_retrieve_body($response);
8698 $decoded_response = json_decode($response_body, true);
8699
8700 if (isset($decoded_response['choices'][0]['message']['content'])) {
8701 return trim($decoded_response['choices'][0]['message']['content']);
8702 } else {
8703 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
8704 return [
8705 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
8706 'error_code' => 'deepseek_response_format_error',
8707 'provider' => 'deepseek'
8708 ];
8709 }
8710 } catch (Exception $e) {
8711 //error_log('DeepSeek Exception: ' . $e->getMessage());
8712 return [
8713 'error' => esc_html__('System error when processing DeepSeek request: ', 'mxchat') . esc_html($e->getMessage()),
8714 'error_code' => 'deepseek_exception',
8715 'provider' => 'deepseek'
8716 ];
8717 }
8718 }
8719 private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
8720 // Get bot ID from session or request
8721 $bot_id = $this->get_current_bot_id($session_id);
8722
8723 // Get system prompt instructions using centralized function
8724 $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
8725
8726 // Add system prompt to relevant content
8727 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
8728
8729 // Format messages for Gemini API
8730 $formatted_messages = [];
8731
8732 // Add system message as the first user message with role prefix
8733 // Note: Gemini doesn't have a dedicated system role, so we use a prefixed user message
8734 $formatted_messages[] = [
8735 'role' => 'user',
8736 'parts' => [
8737 ['text' => "[System Instructions] " . $content_with_instructions]
8738 ]
8739 ];
8740
8741 // Add model response to acknowledge system instructions
8742 $formatted_messages[] = [
8743 'role' => 'model',
8744 'parts' => [
8745 ['text' => "I understand and will follow these instructions."]
8746 ]
8747 ];
8748
8749 // Process the rest of the conversation history
8750 $current_role = null;
8751 $current_parts = [];
8752
8753 foreach ($conversation_history as $message) {
8754 // Skip the first system message as we already handled it
8755 if ($message['role'] === 'system') {
8756 continue;
8757 }
8758
8759 // Map roles to Gemini format
8760 $gemini_role = '';
8761 if ($message['role'] === 'user') {
8762 $gemini_role = 'user';
8763 } else if (in_array($message['role'], ['assistant', 'bot', 'agent'])) {
8764 $gemini_role = 'model';
8765 } else {
8766 // Skip unsupported roles
8767 continue;
8768 }
8769
8770 // If we have a new role, add the previous message
8771 if ($current_role !== null && $current_role !== $gemini_role && !empty($current_parts)) {
8772 $formatted_messages[] = [
8773 'role' => $current_role,
8774 'parts' => $current_parts
8775 ];
8776 $current_parts = [];
8777 }
8778
8779 // Set current role and add text to parts
8780 $current_role = $gemini_role;
8781 $current_parts[] = ['text' => $message['content']];
8782 }
8783
8784 // Add the last message if there's content
8785 if ($current_role !== null && !empty($current_parts)) {
8786 $formatted_messages[] = [
8787 'role' => $current_role,
8788 'parts' => $current_parts
8789 ];
8790 }
8791
8792 // Build the request body
8793 $body = json_encode([
8794 'contents' => $formatted_messages,
8795 'generationConfig' => [
8796 'temperature' => 0.7,
8797 'topP' => 0.95,
8798 'topK' => 40,
8799 'maxOutputTokens' => 8192,
8800 ],
8801 'safetySettings' => [
8802 [
8803 'category' => 'HARM_CATEGORY_HARASSMENT',
8804 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8805 ],
8806 [
8807 'category' => 'HARM_CATEGORY_HATE_SPEECH',
8808 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8809 ],
8810 [
8811 'category' => 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
8812 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8813 ],
8814 [
8815 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
8816 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
8817 ]
8818 ]
8819 ]);
8820
8821 // Prepare the API endpoint
8822 // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models
8823 $api_version = (strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
8824 $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
8825
8826 // Set up the API request
8827 $args = [
8828 'body' => $body,
8829 'headers' => [
8830 'Content-Type' => 'application/json',
8831 ],
8832 'timeout' => 60,
8833 'redirection' => 5,
8834 'blocking' => true,
8835 'httpversion' => '1.0',
8836 'sslverify' => true,
8837 ];
8838
8839 // Make the API request
8840 $response = wp_remote_post($api_endpoint, $args);
8841
8842 // Process the response
8843 if (is_wp_error($response)) {
8844 return "Sorry, there was an error processing your request: " . $response->get_error_message();
8845 }
8846
8847 $response_body = json_decode(wp_remote_retrieve_body($response), true);
8848
8849 // Handle potential errors in the response
8850 if (isset($response_body['error'])) {
8851 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
8852 return "Sorry, there was an error with the Gemini API: " .
8853 (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
8854 }
8855
8856 // Extract the response text
8857 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
8858 return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
8859 } else {
8860 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
8861 return "Sorry, I couldn't process that request. The response format was unexpected.";
8862 }
8863 }
8864
8865
8866 public function test_streaming_request() {
8867 $options = get_option('mxchat_options', []);
8868 $model = $options['model'] ?? 'gpt-5.1-chat-latest';
8869
8870 // Detect provider from model prefix
8871 $provider = strtolower(explode('-', $model)[0]);
8872
8873 $sample_prompt = 'Hello! Can you stream this response back to me?';
8874 $messages = [['role' => 'user', 'content' => $sample_prompt]];
8875 $headers = [];
8876 $body = [];
8877 $url = '';
8878 $api_key = '';
8879
8880 switch ($provider) {
8881 case 'gpt':
8882 case 'o1':
8883 $api_key = $options['api_key'] ?? '';
8884 if (empty($api_key)) return '❌ Missing API key for OpenAI';
8885 $url = 'https://api.openai.com/v1/chat/completions';
8886 $headers = [
8887 'Content-Type: application/json',
8888 'Authorization: Bearer ' . $api_key
8889 ];
8890 $body = [
8891 'model' => $model,
8892 'messages' => $messages,
8893 'stream' => true
8894 ];
8895 break;
8896
8897 case 'claude':
8898 $api_key = $options['claude_api_key'] ?? '';
8899 if (empty($api_key)) return '❌ Missing API key for Claude';
8900 $url = 'https://api.anthropic.com/v1/messages';
8901 $headers = [
8902 'Content-Type: application/json',
8903 'x-api-key: ' . $api_key,
8904 'anthropic-version: 2023-06-01'
8905 ];
8906 $body = [
8907 'model' => $model,
8908 'messages' => $messages,
8909 'max_tokens' => 100,
8910 'stream' => true
8911 ];
8912 break;
8913
8914 case 'grok':
8915 $api_key = $options['xai_api_key'] ?? '';
8916 if (empty($api_key)) return '❌ Missing API key for X.AI';
8917 $url = 'https://api.x.ai/v1/chat/completions';
8918 $headers = [
8919 'Content-Type: application/json',
8920 'Authorization: Bearer ' . $api_key
8921 ];
8922 $body = [
8923 'model' => $model,
8924 'messages' => $messages,
8925 'stream' => true
8926 ];
8927 break;
8928
8929 case 'deepseek':
8930 if (empty($deepseek_api_key)) {
8931 $error_response = [
8932 'error' => esc_html__('DeepSeek API key is not configured', 'mxchat'),
8933 'error_code' => 'missing_deepseek_api_key'
8934 ];
8935 if ($testing_data !== null) {
8936 $error_response['testing_data'] = $testing_data;
8937 }
8938 return $error_response;
8939 }
8940 if ($streaming) {
8941 return $this->mxchat_generate_response_deepseek_stream(
8942 $selected_model,
8943 $deepseek_api_key,
8944 $conversation_history,
8945 $relevant_content,
8946 $session_id,
8947 $testing_data // Pass testing data
8948 );
8949 } else {
8950 $response = $this->mxchat_generate_response_deepseek(
8951 $selected_model,
8952 $deepseek_api_key,
8953 $conversation_history,
8954 $relevant_content
8955 );
8956 }
8957 break;
8958
8959 case 'gemini':
8960 $api_key = $options['gemini_api_key'] ?? '';
8961 if (empty($api_key)) return '❌ Missing API key for Gemini';
8962 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':streamGenerateContent?key=' . $api_key;
8963 $headers = ['Content-Type: application/json'];
8964 $body = [
8965 'contents' => [['role' => 'user', 'parts' => [['text' => $sample_prompt]]]],
8966 'generationConfig' => ['temperature' => 0.7]
8967 ];
8968 break;
8969
8970 default:
8971 return '❌ Unsupported provider: ' . $provider;
8972 }
8973
8974 // Do the actual streaming test
8975 $ch = curl_init($url);
8976 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
8977 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
8978 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
8979 curl_setopt($ch, CURLOPT_TIMEOUT, 15);
8980 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
8981
8982 $response = curl_exec($ch);
8983 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
8984 $error = curl_error($ch);
8985 curl_close($ch);
8986
8987 if ($error) return "❌ cURL error: $error";
8988 if ($http_code !== 200) {
8989 $error_message = json_decode($response, true)['error']['message'] ?? 'Unknown';
8990 return "❌ HTTP $http_code: $error_message";
8991 }
8992
8993 return true;
8994 }
8995
8996 public function mxchat_dismiss_pre_chat_message() {
8997 // Get and sanitize the user identifier
8998 $user_id = $this->mxchat_get_user_identifier();
8999 $user_id = sanitize_key($user_id);
9000
9001 // Set a transient to track that the user has dismissed the pre-chat message
9002 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9003 set_transient($transient_key, true, DAY_IN_SECONDS);
9004
9005 wp_send_json_success();
9006 }
9007
9008 public function mxchat_check_pre_chat_message_status() {
9009 // Get and sanitize the user identifier
9010 $user_id = $this->mxchat_get_user_identifier();
9011 $user_id = sanitize_key($user_id);
9012
9013 // Check if the transient exists (i.e., if the message was dismissed)
9014 $transient_key = 'mxchat_pre_chat_message_dismissed_' . $user_id;
9015 $dismissed = get_transient($transient_key);
9016
9017 // Log the result to see if it's being set correctly
9018 //error_log("Check pre-chat message dismissed for $user_id: " . ($dismissed ? 'Yes' : 'No'));
9019
9020 if ($dismissed) {
9021 wp_send_json_success(['dismissed' => true]);
9022 } else {
9023 wp_send_json_success(['dismissed' => false]);
9024 }
9025
9026 wp_die();
9027 }
9028
9029 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
9030 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
9031 return 0;
9032 }
9033
9034 $dotProduct = array_sum(array_map(function ($a, $b) {
9035 return $a * $b;
9036 }, $vectorA, $vectorB));
9037 $normA = sqrt(array_sum(array_map(function ($a) {
9038 return $a * $a;
9039 }, $vectorA)));
9040 $normB = sqrt(array_sum(array_map(function ($b) {
9041 return $b * $b;
9042 }, $vectorB)));
9043
9044 if ($normA == 0 || $normB == 0) {
9045 return 0;
9046 }
9047
9048 return $dotProduct / ($normA * $normB);
9049 }
9050
9051
9052 public function mxchat_enqueue_scripts_styles() {
9053 // Enqueue the script
9054 wp_enqueue_script(
9055 'mxchat-chat-js',
9056 plugin_dir_url(__FILE__) . '../js/chat-script.js',
9057 array('jquery'),
9058 MXCHAT_VERSION,
9059 true
9060 );
9061 // Enqueue the CSS
9062 wp_enqueue_style(
9063 'mxchat-chat-css',
9064 plugin_dir_url(__FILE__) . '../css/chat-style.css',
9065 array(),
9066 MXCHAT_VERSION
9067 );
9068 // Fetch options from the database
9069 $this->options = get_option('mxchat_options');
9070 $prompts_options = get_option('mxchat_prompts_options', array());
9071
9072 // Check if AI theme is active - if so, skip inline colors in JavaScript
9073 $theme_options = get_option('mxchat_theme_options', array());
9074 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
9075 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
9076 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
9077
9078 // Prepare settings for JavaScript
9079 $style_settings = array(
9080 'ajax_url' => admin_url('admin-ajax.php'),
9081 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
9082 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest',
9083 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
9084 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
9085 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
9086 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
9087 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
9088 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
9089 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
9090 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
9091 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
9092 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
9093 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
9094 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
9095 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
9096 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
9097 'icon_color' => $this->options['icon_color'] ?? '#fff',
9098 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
9099 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
9100 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
9101 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
9102 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
9103 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
9104 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
9105 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
9106 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
9107 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
9108 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
9109 'initial_email_state' => null, // Also fixed this undefined variable
9110 'skip_email_check' => true,
9111 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
9112 'skip_inline_colors' => $skip_inline_colors,
9113 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
9114 );
9115 // Pass the settings to the script
9116 wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
9117 }
9118
9119
9120 /**
9121 * Setup the cron jobs for rate limits with guard against multiple calls
9122 */
9123 public function setup_rate_limit_cron_jobs() {
9124 // Add a guard to prevent multiple rapid calls
9125 $last_setup = get_transient('mxchat_cron_setup_guard');
9126 if ($last_setup && (time() - $last_setup) < 60) {
9127 // Don't run again if we ran less than 60 seconds ago
9128 return;
9129 }
9130
9131 // Set the guard
9132 set_transient('mxchat_cron_setup_guard', time(), 300); // 5 minutes
9133
9134 try {
9135 // First, check if WordPress cron is disabled
9136 if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
9137 //error_log('MxChat: WordPress cron is disabled (DISABLE_WP_CRON = true), using fallback system');
9138 $this->setup_fallback_rate_limit_system();
9139 return;
9140 }
9141
9142 // Check if cron is already scheduled - if so, don't mess with it
9143 if (wp_next_scheduled('mxchat_reset_rate_limits')) {
9144 //error_log('MxChat: Rate limit cron already scheduled, skipping setup');
9145 return;
9146 }
9147
9148 // Clear any orphaned hooks (but don't loop indefinitely)
9149 $hooks_to_clear = [
9150 'mxchat_reset_rate_limits',
9151 'mxchat_reset_hourly_rate_limits',
9152 'mxchat_reset_daily_rate_limits',
9153 'mxchat_reset_weekly_rate_limits',
9154 'mxchat_reset_monthly_rate_limits'
9155 ];
9156
9157 foreach ($hooks_to_clear as $hook) {
9158 // Only clear a maximum of 3 instances to prevent infinite loops
9159 $cleared = 0;
9160 while (wp_next_scheduled($hook) && $cleared < 3) {
9161 wp_clear_scheduled_hook($hook);
9162 $cleared++;
9163 }
9164 }
9165
9166 // Small delay after clearing
9167 usleep(100000); // 0.1 seconds
9168
9169 // Try to schedule the event
9170 $initial_time = time() + 300; // Start in 5 minutes
9171 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
9172
9173 if ($result === false) {
9174 //error_log('MxChat: Failed to schedule cron, using fallback system');
9175 $this->setup_fallback_rate_limit_system();
9176 } else {
9177 //error_log('MxChat: Successfully scheduled rate limit reset cron');
9178 }
9179
9180 } catch (Exception $e) {
9181 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
9182 $this->setup_fallback_rate_limit_system();
9183 }
9184 }
9185
9186 /**
9187 * Try alternative cron scheduling methods
9188 */
9189 private function try_alternative_cron_scheduling($initial_time) {
9190 try {
9191 // Method 1: Try with current time instead of future time
9192 $result1 = wp_schedule_event(time(), 'hourly', 'mxchat_reset_rate_limits');
9193 if ($result1 !== false) {
9194 //error_log('MxChat: Alternative method 1 (current time) succeeded');
9195 return true;
9196 }
9197
9198 // Method 2: Try with a different interval
9199 $result2 = wp_schedule_event($initial_time, 'daily', 'mxchat_reset_rate_limits');
9200 if ($result2 !== false) {
9201 //error_log('MxChat: Alternative method 2 (daily interval) succeeded');
9202 return true;
9203 }
9204
9205 // Method 3: Try wp_schedule_single_event first, then recurring
9206 $result3 = wp_schedule_single_event($initial_time, 'mxchat_reset_rate_limits');
9207 if ($result3 !== false) {
9208 //error_log('MxChat: Alternative method 3 (single event) succeeded');
9209 // Schedule the next one manually in the handler
9210 return true;
9211 }
9212
9213 return false;
9214
9215 } catch (Exception $e) {
9216 //error_log('MxChat: Alternative cron scheduling exception: ' . $e->getMessage());
9217 return false;
9218 }
9219 }
9220
9221 /**
9222 * Enhanced fallback rate limit system
9223 */
9224 private function setup_fallback_rate_limit_system() {
9225 // Set a flag to use database-based rate limit cleanup
9226 update_option('mxchat_use_fallback_rate_limits', true);
9227
9228 // Schedule a one-time check to happen on the next plugin load
9229 update_option('mxchat_next_rate_limit_check', time() + 3600);
9230
9231 // Also set up a more frequent fallback check (every 4 hours)
9232 update_option('mxchat_fallback_check_interval', 4 * 3600);
9233
9234 //error_log('MxChat: Fallback rate limit system activated');
9235 }
9236
9237 /**
9238 * Enhanced fallback check method
9239 */
9240 public function check_fallback_rate_limits() {
9241 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9242
9243 if (!$use_fallback) {
9244 return; // Regular cron is working
9245 }
9246
9247 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9248 $check_interval = get_option('mxchat_fallback_check_interval', 3600);
9249
9250 if (time() >= $next_check) {
9251 //error_log('MxChat: Running fallback rate limit cleanup');
9252 $this->mxchat_reset_rate_limits();
9253
9254 // Schedule next check
9255 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
9256 }
9257 }
9258 /**
9259 * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
9260 */
9261 public function check_rate_limit() {
9262 // Check if we need to run fallback cleanup
9263 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
9264 $next_check = get_option('mxchat_next_rate_limit_check', 0);
9265
9266 if ($use_fallback && time() >= $next_check) {
9267 $this->mxchat_reset_rate_limits();
9268 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
9269 }
9270
9271 // Get bot ID from current request context
9272 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
9273
9274 // Get bot-specific options (includes rate limits if overridden)
9275 $bot_options = $this->get_bot_options($bot_id);
9276 $current_options = !empty($bot_options) ? $bot_options : $this->options;
9277
9278 // Use bot-specific rate limits if available, otherwise fall back to default
9279 $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
9280
9281 // Determine user role or if logged out
9282 if (is_user_logged_in()) {
9283 $user = wp_get_current_user();
9284 $user_id = $user->ID;
9285
9286 // Get the user's primary role using reset() to safely get the first element
9287 $user_roles = $user->roles;
9288
9289 // Safely get the first role regardless of array key structure
9290 if (!empty($user_roles) && is_array($user_roles)) {
9291 $role = reset($user_roles); // This safely gets the first element regardless of key
9292 } else {
9293 $role = 'subscriber'; // Default to subscriber if no role found
9294 }
9295 } else {
9296 $role = 'logged_out';
9297 // Use IP address for non-logged-in users
9298 $user_id = $this->get_client_ip();
9299 }
9300
9301 // Check if rate limits are configured for this role
9302 if (!isset($rate_limits_source[$role])) {
9303 return true; // No limit set for this role
9304 }
9305
9306 $limit = $rate_limits_source[$role]['limit'];
9307
9308 // If unlimited, return true immediately
9309 if ($limit === 'unlimited') {
9310 return true;
9311 }
9312
9313 // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
9314 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
9315 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
9316 $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
9317
9318 // Include bot_id in option name so each bot has separate rate limits
9319 $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
9320
9321 // Get the counter data
9322 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
9323
9324 // If first request or counter reset needed, set the initial timestamp
9325 if ($limit_data['count'] === 0) {
9326 $limit_data['timestamp'] = time();
9327 update_option($option_name, $limit_data);
9328 }
9329
9330 // Get the timeframe
9331 $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
9332 $rate_limits_source[$role]['timeframe'] : 'daily';
9333
9334 // Check if the counter needs to be reset based on timeframe
9335 $current_time = time();
9336 $timestamp = $limit_data['timestamp'];
9337 $should_reset = false;
9338
9339 switch ($timeframe) {
9340 case 'hourly':
9341 $should_reset = ($current_time - $timestamp) >= 3600; // 1 hour
9342 break;
9343 case 'daily':
9344 $should_reset = ($current_time - $timestamp) >= 86400; // 24 hours
9345 break;
9346 case 'weekly':
9347 $should_reset = ($current_time - $timestamp) >= 604800; // 7 days
9348 break;
9349 case 'monthly':
9350 $should_reset = ($current_time - $timestamp) >= 2592000; // 30 days
9351 break;
9352 }
9353
9354 // Reset the counter if the timeframe has passed
9355 if ($should_reset) {
9356 $limit_data = ['count' => 0, 'timestamp' => $current_time];
9357 update_option($option_name, $limit_data);
9358 }
9359
9360 // Check if user has exceeded their limit
9361 if ($limit_data['count'] >= intval($limit)) {
9362 // Get the custom message for this role
9363 $message = !empty($rate_limits_source[$role]['message'])
9364 ? $rate_limits_source[$role]['message']
9365 : __('Rate limit exceeded. Please try again later.', 'mxchat');
9366
9367 // Add timeframe information to the message if placeholders exist
9368 $timeframe_label = '';
9369 switch ($timeframe) {
9370 case 'hourly':
9371 $timeframe_label = __('hour', 'mxchat');
9372 break;
9373 case 'daily':
9374 $timeframe_label = __('day', 'mxchat');
9375 break;
9376 case 'weekly':
9377 $timeframe_label = __('week', 'mxchat');
9378 break;
9379 case 'monthly':
9380 $timeframe_label = __('month', 'mxchat');
9381 break;
9382 }
9383
9384 // Replace placeholders in the message
9385 $message = str_replace(
9386 ['{limit}', '{count}', '{remaining}', '{timeframe}'],
9387 [intval($limit), $limit_data['count'], max(0, intval($limit) - $limit_data['count']), $timeframe_label],
9388 $message
9389 );
9390
9391 // Process HTML links in the message
9392 $message = $this->process_rate_limit_message_html($message);
9393
9394 // Return error with the processed message
9395 return [
9396 'error' => true,
9397 'message' => $message
9398 ];
9399 }
9400
9401 // Increment the counter
9402 $limit_data['count']++;
9403 update_option($option_name, $limit_data);
9404
9405 return true;
9406 }
9407
9408 /**
9409 * Enhanced rate limit reset with better error handling
9410 */
9411 public function mxchat_reset_rate_limits() {
9412 try {
9413 global $wpdb;
9414 $all_options = get_option('mxchat_options', []);
9415 $current_time = time();
9416
9417 // Get rate limit options with a safer query and limit
9418 $option_names = $wpdb->get_col(
9419 $wpdb->prepare(
9420 "SELECT option_name FROM {$wpdb->options}
9421 WHERE option_name LIKE %s
9422 LIMIT 1000",
9423 'mxchat_chat_limit_%'
9424 )
9425 );
9426
9427 if (empty($option_names)) {
9428 return;
9429 }
9430
9431 $processed_count = 0;
9432 $max_processing_time = 30; // Maximum 30 seconds
9433 $start_time = time();
9434
9435 foreach ($option_names as $option_name) {
9436 // Check processing time limit
9437 if ((time() - $start_time) > $max_processing_time) {
9438 //error_log('MxChat: Rate limit reset timeout after processing ' . $processed_count . ' entries');
9439 break;
9440 }
9441
9442 // Parse the option name more safely
9443 if (!preg_match('/^mxchat_chat_limit_(.+)_(.+)$/', $option_name, $matches)) {
9444 continue;
9445 }
9446
9447 $role_and_user = $matches[1] . '_' . $matches[2];
9448 $parts = explode('_', $role_and_user);
9449
9450 if (count($parts) < 2) {
9451 continue;
9452 }
9453
9454 // Extract role (everything except the last part which is user ID)
9455 $user_id_part = array_pop($parts);
9456 $role = implode('_', $parts);
9457
9458 // Skip if role doesn't exist in our settings
9459 if (!isset($all_options['rate_limits'][$role])) {
9460 // Clean up orphaned entries
9461 delete_option($option_name);
9462 continue;
9463 }
9464
9465 $timeframe = $all_options['rate_limits'][$role]['timeframe'] ?? 'daily';
9466 $limit_data = get_option($option_name);
9467
9468 if (!$limit_data || !is_array($limit_data) || !isset($limit_data['timestamp'])) {
9469 // Clean up invalid entries
9470 delete_option($option_name);
9471 continue;
9472 }
9473
9474 $timestamp = $limit_data['timestamp'];
9475 $should_reset = false;
9476
9477 // Determine if we should reset based on the timeframe
9478 switch ($timeframe) {
9479 case 'hourly':
9480 $should_reset = ($current_time - $timestamp) >= 3600;
9481 break;
9482 case 'daily':
9483 $should_reset = ($current_time - $timestamp) >= 86400;
9484 break;
9485 case 'weekly':
9486 $should_reset = ($current_time - $timestamp) >= 604800;
9487 break;
9488 case 'monthly':
9489 $should_reset = ($current_time - $timestamp) >= 2592000;
9490 break;
9491 }
9492
9493 // Reset the counter if the timeframe has passed
9494 if ($should_reset) {
9495 delete_option($option_name);
9496 wp_cache_delete($option_name, 'options');
9497 $processed_count++;
9498 }
9499 }
9500
9501 // Clean up any orphaned cache entries
9502 wp_cache_delete('mxchat_all_chat_limits', 'options');
9503
9504 //error_log("MxChat: Rate limit reset completed. Processed {$processed_count} entries.");
9505
9506 } catch (Exception $e) {
9507 //error_log('MxChat: Rate limit reset error: ' . $e->getMessage());
9508 }
9509 }
9510
9511
9512 /**
9513 * Process HTML links in rate limit messages
9514 *
9515 * @param string $message The rate limit message
9516 * @return string The processed message with safe HTML links
9517 */
9518 private function process_rate_limit_message_html($message) {
9519 // Return original message if empty
9520 if (empty($message)) {
9521 return $message;
9522 }
9523
9524 // First, convert markdown links to HTML
9525 $message = $this->convert_markdown_links($message);
9526
9527 // Then, auto-convert any remaining plain URLs to links
9528 $message = $this->auto_link_urls($message);
9529
9530 // Allow basic HTML tags for links and formatting
9531 $allowed_tags = [
9532 'a' => [
9533 'href' => true,
9534 'target' => true,
9535 'rel' => true,
9536 'title' => true,
9537 'class' => true
9538 ],
9539 'strong' => [],
9540 'em' => [],
9541 'br' => [],
9542 'b' => [],
9543 'i' => [],
9544 'span' => ['class' => true]
9545 ];
9546
9547 // Sanitize but allow the specified HTML tags
9548 $processed_message = wp_kses($message, $allowed_tags);
9549
9550 // If wp_kses stripped everything, return the original message as plain text
9551 if (empty($processed_message) && !empty($message)) {
9552 // Strip all HTML and return plain text as fallback
9553 return wp_strip_all_tags($message);
9554 }
9555
9556 return $processed_message;
9557 }
9558
9559 /**
9560 * Convert markdown links to HTML
9561 *
9562 * @param string $text The text to process
9563 * @return string The text with markdown links converted to HTML
9564 */
9565 private function convert_markdown_links($text) {
9566 // Return original text if empty
9567 if (empty($text)) {
9568 return $text;
9569 }
9570
9571 // Pattern to match markdown links: [text](url)
9572 $pattern = '/\[([^\]]+)\]\(([^)]+)\)/';
9573
9574 $processed_text = preg_replace_callback($pattern, function($matches) {
9575 $link_text = $matches[1];
9576 $url = $matches[2];
9577
9578 // Clean up any trailing punctuation from the URL
9579 $url = rtrim($url, '.,;:!?');
9580
9581 // Sanitize the link text and URL
9582 $safe_text = esc_html($link_text);
9583 $safe_url = esc_url($url);
9584
9585 // Create the HTML link
9586 return '<a href="' . $safe_url . '" target="_blank" rel="noopener noreferrer">' . $safe_text . '</a>';
9587 }, $text);
9588
9589 // If preg_replace_callback failed, return original text
9590 if ($processed_text === null) {
9591 return $text;
9592 }
9593
9594 return $processed_text;
9595 }
9596
9597 /**
9598 * Auto-convert plain URLs to clickable links
9599 *
9600 * @param string $text The text to process
9601 * @return string The text with URLs converted to links
9602 */
9603 private function auto_link_urls($text) {
9604 // Return original text if empty
9605 if (empty($text)) {
9606 return $text;
9607 }
9608
9609 // Simple pattern that avoids complex lookbehinds
9610 // This will match URLs that are not already inside href attributes or markdown links
9611 $pattern = '/(?<!href=["\'])(?<!\]\()https?:\/\/[^\s<>"\')\]]+/i';
9612
9613 $processed_text = preg_replace_callback($pattern, function($matches) {
9614 $url = $matches[0];
9615 // Clean up any trailing punctuation that might have been captured
9616 $url = rtrim($url, '.,;:!?');
9617
9618 // Add target="_blank" and rel="noopener noreferrer" for security
9619 return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
9620 }, $text);
9621
9622 // If preg_replace_callback failed, return original text
9623 if ($processed_text === null) {
9624 return $text;
9625 }
9626
9627 return $processed_text;
9628 }
9629
9630
9631 // Helper function to get client IP address
9632 private function get_client_ip() {
9633 // Check for shared internet/ISP IP
9634 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
9635 return sanitize_text_field($_SERVER['HTTP_CLIENT_IP']);
9636 }
9637
9638 // Check for IPs passing through proxies
9639 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
9640 // Use the first value in the comma-separated list
9641 $forwarded_for = explode(',', sanitize_text_field($_SERVER['HTTP_X_FORWARDED_FOR']));
9642 return trim($forwarded_for[0]);
9643 }
9644
9645 if (!empty($_SERVER['REMOTE_ADDR'])) {
9646 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
9647 }
9648
9649 // Fallback
9650 return 'unknown';
9651 }
9652
9653 /**
9654 * AJAX handler to get system information for testing panel
9655 */
9656 /**
9657 * AJAX handler to get system information for testing panel
9658 */
9659 public function mxchat_get_system_info() {
9660 // Verify nonce for security
9661 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9662 wp_send_json_error(['message' => 'Invalid nonce']);
9663 return;
9664 }
9665
9666 // Only allow admin users
9667 if (!current_user_can('administrator')) {
9668 wp_send_json_error(['message' => 'Unauthorized']);
9669 return;
9670 }
9671
9672 // Get system prompt from options
9673 $system_prompt = isset($this->options['system_prompt_instructions'])
9674 ? $this->options['system_prompt_instructions']
9675 : 'No system prompt configured';
9676
9677 // Get selected model
9678 $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.1-chat-latest';
9679
9680 // Check if OpenRouter is being used
9681 $is_openrouter = ($selected_model === 'openrouter');
9682 $openrouter_model = '';
9683
9684 if ($is_openrouter) {
9685 // Get the actual OpenRouter model that's selected
9686 $openrouter_model = isset($this->options['openrouter_selected_model'])
9687 ? $this->options['openrouter_selected_model']
9688 : 'No OpenRouter model selected';
9689
9690 // Update selected_model display to show both
9691 $selected_model = 'OpenRouter: ' . $openrouter_model;
9692 }
9693
9694 // Get API key status (just check if they exist, don't expose the keys)
9695 $api_status = [];
9696 $api_status['openai'] = !empty($this->options['api_key']);
9697 $api_status['claude'] = !empty($this->options['claude_api_key']);
9698 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
9699 $api_status['xai'] = !empty($this->options['xai_api_key']);
9700 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
9701 $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
9702
9703 wp_send_json_success([
9704 'system_prompt' => $system_prompt,
9705 'selected_model' => $selected_model,
9706 'is_openrouter' => $is_openrouter,
9707 'openrouter_model' => $openrouter_model,
9708 'api_status' => $api_status
9709 ]);
9710 }
9711
9712 /**
9713 * AJAX handler to get similarity threshold
9714 */
9715 public function mxchat_get_similarity_threshold() {
9716 // Verify nonce for security
9717 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9718 wp_send_json_error(['message' => 'Invalid nonce']);
9719 return;
9720 }
9721
9722 // Only allow admin users
9723 if (!current_user_can('administrator')) {
9724 wp_send_json_error(['message' => 'Unauthorized']);
9725 return;
9726 }
9727
9728 // Get similarity threshold from main options (default 35%)
9729 $similarity_threshold = isset($this->options['similarity_threshold'])
9730 ? ((int) $this->options['similarity_threshold']) / 100
9731 : 0.35;
9732
9733 wp_send_json_success([
9734 'threshold' => $similarity_threshold,
9735 'threshold_percentage' => ($similarity_threshold * 100) . '%'
9736 ]);
9737 }
9738
9739 /**
9740 * AJAX handler to get knowledge base status
9741 */
9742 public function mxchat_get_kb_status() {
9743 // Verify nonce for security
9744 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9745 wp_send_json_error(['message' => 'Invalid nonce']);
9746 return;
9747 }
9748
9749 // Only allow admin users
9750 if (!current_user_can('administrator')) {
9751 wp_send_json_error(['message' => 'Unauthorized']);
9752 return;
9753 }
9754
9755 // Check OpenAI Vector Store first (takes priority)
9756 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
9757 $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
9758
9759 if ($use_vectorstore) {
9760 $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
9761 $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
9762
9763 $kb_info = [
9764 'type' => 'OpenAI Vector Store',
9765 'status' => 'Active',
9766 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
9767 ];
9768
9769 wp_send_json_success($kb_info);
9770 return;
9771 }
9772
9773 // Check Pinecone vs WordPress
9774 $addon_options = get_option('mxchat_pinecone_addon_options', array());
9775 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
9776
9777 $kb_info = [
9778 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
9779 'status' => 'Active'
9780 ];
9781
9782 // Get document count
9783 if ($use_pinecone) {
9784 $kb_info['documents'] = 'Connected to Pinecone';
9785 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
9786 } else {
9787 // Count documents in WordPress database
9788 global $wpdb;
9789 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
9790 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
9791 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
9792 }
9793
9794 wp_send_json_success($kb_info);
9795 }
9796
9797 /**
9798 * AJAX handler to start a completely fresh session (NEW - replaces old clear session)
9799 */
9800 public function mxchat_start_fresh_session() {
9801 // Verify nonce for security
9802 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
9803 wp_send_json_error(['message' => 'Invalid nonce']);
9804 return;
9805 }
9806
9807 // Only allow admin users
9808 if (!current_user_can('administrator')) {
9809 wp_send_json_error(['message' => 'Unauthorized']);
9810 return;
9811 }
9812
9813 $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
9814 $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
9815
9816 if (empty($old_session_id)) {
9817 wp_send_json_error(['message' => 'Old session ID required']);
9818 return;
9819 }
9820
9821 // If no new session ID provided, generate one
9822 if (empty($new_session_id)) {
9823 $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
9824 }
9825
9826 // Clear ALL data associated with the old session
9827 $this->clear_complete_session_data($old_session_id);
9828
9829 // Initialize the new session
9830 $this->initialize_fresh_session($new_session_id);
9831
9832 wp_send_json_success([
9833 'message' => 'Fresh session started successfully',
9834 'new_session_id' => $new_session_id,
9835 'old_session_id' => $old_session_id
9836 ]);
9837 }
9838
9839 /**
9840 * Clear ALL data associated with a session (ENHANCED)
9841 */
9842 private function clear_complete_session_data($session_id) {
9843 // Clear chat history
9844 delete_option("mxchat_history_{$session_id}");
9845
9846 // Clear chat mode
9847 delete_option("mxchat_mode_{$session_id}");
9848
9849 // Clear any PDF/Word transients
9850 $this->clear_pdf_transients($session_id);
9851 if (method_exists($this, 'clear_word_transients')) {
9852 $this->clear_word_transients($session_id);
9853 }
9854
9855 // Clear agent-related data
9856 delete_option("mxchat_channel_{$session_id}");
9857 delete_option("mxchat_agent_name_{$session_id}");
9858 delete_option("mxchat_email_{$session_id}");
9859
9860 // Clear any recommendation flow state
9861 delete_option("mxchat_sr_flow_state_{$session_id}");
9862
9863 // Clear any cached embeddings or context
9864 delete_transient("mxchat_context_{$session_id}");
9865 delete_transient("mxchat_last_query_{$session_id}");
9866
9867 // Clear any testing data
9868 delete_transient("mxchat_testing_data_{$session_id}");
9869
9870 // Clear any rate limiting data for this session
9871 delete_transient("mxchat_rate_limit_{$session_id}");
9872
9873 // Clear any other session-specific transients
9874 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
9875 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
9876 delete_transient("mxchat_include_word_in_context_{$session_id}");
9877
9878 // Clear form addon state (pending forms and submitted forms)
9879 delete_option("mxchat_pending_form_{$session_id}");
9880 delete_option("mxchat_submitted_forms_{$session_id}");
9881
9882 //error_log("MxChat: Cleared all data for session: {$session_id}");
9883 }
9884
9885 /**
9886 * Initialize a fresh session with default data
9887 */
9888 private function initialize_fresh_session($session_id) {
9889 // Set default chat mode
9890 update_option("mxchat_mode_{$session_id}", 'ai');
9891
9892 //error_log("MxChat: Initialized fresh session: {$session_id}");
9893 }
9894
9895 /**
9896 * Helper method to clear Word document transients (if you have Word support)
9897 */
9898 private function clear_word_transients($session_id) {
9899 delete_transient('mxchat_word_url_' . $session_id);
9900 delete_transient('mxchat_word_filename_' . $session_id);
9901 delete_transient('mxchat_word_embeddings_' . $session_id);
9902 delete_transient('mxchat_include_word_in_context_' . $session_id);
9903 }
9904
9905 /**
9906 * Simplified testing data capture method (CLEANED UP)
9907 */
9908 private function capture_testing_data($user_embedding, $message, $session_id) {
9909 // Only capture for admin users
9910 if (!current_user_can('administrator')) {
9911 return null;
9912 }
9913
9914 $testing_data = [
9915 'query' => $message,
9916 'timestamp' => time(),
9917 'top_matches' => [],
9918 'action_matches' => [] // Add action matches
9919 ];
9920
9921 // Get similarity threshold
9922 $similarity_threshold = isset($this->options['similarity_threshold'])
9923 ? ((int) $this->options['similarity_threshold']) / 100
9924 : 0.35;
9925
9926 $testing_data['similarity_threshold'] = $similarity_threshold;
9927
9928 // Use the real similarity analysis if available
9929 if ($this->last_similarity_analysis !== null) {
9930 $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
9931 $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
9932 $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9933 } else {
9934 // Fallback: determine knowledge base type
9935 $addon_options = get_option('mxchat_pinecone_addon_options', array());
9936 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
9937
9938 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
9939 }
9940
9941 // Include action analysis if available
9942 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
9943 $testing_data['action_matches'] = $this->last_action_analysis;
9944
9945 // Clear it after capturing to avoid stale data
9946 $this->last_action_analysis = null;
9947 }
9948
9949 return $testing_data;
9950 }
9951
9952
9953 /**
9954 * Track URL clicks from chatbot responses
9955 */
9956 public function mxchat_track_url_click() {
9957 // Verify nonce for security
9958 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
9959 wp_send_json_error(['message' => 'Invalid nonce']);
9960 wp_die();
9961 }
9962
9963 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
9964 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
9965 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
9966
9967 if (empty($session_id) || empty($clicked_url)) {
9968 wp_send_json_error(['message' => 'Missing required data']);
9969 wp_die();
9970 }
9971
9972 global $wpdb;
9973 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
9974
9975 // Insert click tracking record
9976 $wpdb->insert(
9977 $table_name,
9978 [
9979 'session_id' => $session_id,
9980 'clicked_url' => $clicked_url,
9981 'message_context' => $message_context,
9982 'click_timestamp' => current_time('mysql', 1),
9983 'user_ip' => $_SERVER['REMOTE_ADDR'],
9984 'user_agent' => $_SERVER['HTTP_USER_AGENT']
9985 ]
9986 );
9987
9988 wp_send_json_success(['message' => 'Click tracked']);
9989 wp_die();
9990 }
9991
9992 /**
9993 * Get URL click analytics for a session
9994 */
9995 public function mxchat_get_url_clicks($session_id) {
9996 global $wpdb;
9997 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
9998
9999 $clicks = $wpdb->get_results($wpdb->prepare(
10000 "SELECT * FROM $table_name WHERE session_id = %s ORDER BY click_timestamp ASC",
10001 $session_id
10002 ));
10003
10004 return $clicks;
10005 }
10006 /**
10007 * Track the originating page where chat was started
10008 */
10009 public function mxchat_track_originating_page() {
10010 // Verify nonce
10011 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10012 wp_send_json_error(['message' => 'Invalid nonce']);
10013 wp_die();
10014 }
10015
10016 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10017 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
10018 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
10019
10020 if (empty($session_id)) {
10021 wp_send_json_error(['message' => 'Missing session ID']);
10022 wp_die();
10023 }
10024
10025 global $wpdb;
10026 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
10027
10028 // Check if we've already tracked for this session
10029 $existing = $wpdb->get_var($wpdb->prepare(
10030 "SELECT COUNT(*) FROM $table_name
10031 WHERE session_id = %s
10032 AND originating_page_url IS NOT NULL",
10033 $session_id
10034 ));
10035
10036 if ($existing > 0) {
10037 wp_send_json_success(['message' => 'Already tracked']);
10038 wp_die();
10039 }
10040
10041 // Update the first message in this session with originating page info
10042 $wpdb->query($wpdb->prepare(
10043 "UPDATE $table_name
10044 SET originating_page_url = %s,
10045 originating_page_title = %s
10046 WHERE session_id = %s
10047 ORDER BY timestamp ASC
10048 LIMIT 1",
10049 $page_url,
10050 $page_title,
10051 $session_id
10052 ));
10053
10054 wp_send_json_success(['message' => 'Originating page tracked']);
10055 wp_die();
10056 }
10057
10058 /**
10059 * Validate and clean URLs from AI response
10060 * Removes any URLs that aren't in the knowledge base
10061 *
10062 * @param string $response_text The AI-generated response
10063 * @param array $valid_urls Array of URLs from the knowledge base
10064 * @return string Cleaned response with invalid URLs removed/flagged
10065 */
10066 private function validate_and_clean_urls($response_text, $valid_urls) {
10067 // DEBUG: Log what we're working with
10068 error_log("=== MxChat URL Validation Debug ===");
10069 error_log("Valid URLs count: " . count($valid_urls));
10070 error_log("Valid URLs: " . print_r($valid_urls, true));
10071 error_log("Response text length: " . strlen($response_text));
10072 error_log("Response text preview: " . substr($response_text, 0, 500));
10073
10074 // If no valid URLs provided or empty response, return as-is
10075 if (empty($valid_urls) || empty($response_text)) {
10076 error_log("Validation skipped - empty valid_urls or response");
10077 return $response_text;
10078 }
10079
10080 // Extract all URLs from the AI response
10081 // This regex matches http:// and https:// URLs
10082 preg_match_all(
10083 '#\bhttps?://[^\s<>"\')\]]+#i',
10084 $response_text,
10085 $matches
10086 );
10087
10088 // If no URLs found in response, return as-is
10089 if (empty($matches[0])) {
10090 error_log("No URLs found in response");
10091 return $response_text;
10092 }
10093
10094 $found_urls = $matches[0];
10095 $cleaned_response = $response_text;
10096 $removed_count = 0;
10097
10098 // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
10099 $normalized_valid_urls = array_map(function($url) {
10100 // Remove trailing slash
10101 $url = rtrim($url, '/');
10102 // Remove URL fragments (#section)
10103 $url = preg_replace('/#.*$/', '', $url);
10104 // Remove trailing punctuation that might have been captured
10105 $url = rtrim($url, '.,;:!?');
10106 return $url;
10107 }, $valid_urls);
10108
10109 error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
10110
10111 foreach ($found_urls as $found_url) {
10112 // Clean up the found URL (remove trailing punctuation that might have been captured)
10113 $clean_found_url = rtrim($found_url, '.,;:!?)');
10114
10115 // DEBUG: Log each URL being checked
10116 error_log("Checking found URL: " . $found_url);
10117
10118 // Normalize for comparison
10119 $normalized_found = rtrim($clean_found_url, '/');
10120 $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
10121
10122 error_log("Normalized found URL: " . $normalized_found);
10123
10124 // Check if this URL exists in our valid URLs list
10125 $is_valid = false;
10126
10127 error_log("Starting validation checks for: " . $normalized_found);
10128
10129 // First, try exact match
10130 if (in_array($normalized_found, $normalized_valid_urls)) {
10131 $is_valid = true;
10132 error_log("EXACT MATCH FOUND");
10133 } else {
10134 error_log("No exact match, checking variations...");
10135 // If no exact match, check if it's a variation (with query params, etc.)
10136 foreach ($normalized_valid_urls as $valid_url) {
10137 error_log(" Comparing against valid URL: " . $valid_url);
10138
10139 // Check if the found URL starts with a valid URL (handles query params)
10140 if (strpos($normalized_found, $valid_url) === 0) {
10141 // Check what comes after the valid URL
10142 $remainder = substr($normalized_found, strlen($valid_url));
10143
10144 // Only valid if:
10145 // 1. Exact match (remainder is empty)
10146 // 2. Query params (starts with ?)
10147 // 3. Fragment (starts with #)
10148 if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
10149 $is_valid = true;
10150 error_log(" MATCH: Found URL is valid variation of base URL");
10151 break;
10152 } else {
10153 error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
10154 }
10155 }
10156 // Also check the reverse (in case valid URL has query params)
10157 if (strpos($valid_url, $normalized_found) === 0) {
10158 $is_valid = true;
10159 error_log(" MATCH: Valid URL starts with found URL");
10160 break;
10161 }
10162 }
10163
10164 if (!$is_valid) {
10165 error_log("NO MATCH FOUND - URL should be removed");
10166 }
10167 }
10168
10169 // If URL is not valid, remove it from the response
10170 if (!$is_valid) {
10171 // Log the removal for debugging
10172 error_log("MxChat: Removed hallucinated URL: " . $found_url);
10173 error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
10174
10175 $removed_count++;
10176
10177 // Check if URL is part of a markdown link: [text](url)
10178 $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
10179 if (preg_match($markdown_pattern, $cleaned_response)) {
10180 error_log("Found markdown link, removing but keeping text");
10181 // Remove the markdown link but keep the text
10182 $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
10183 }
10184 // Check if URL is part of an HTML link: <a href="url">text</a>
10185 else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
10186 error_log("Found HTML link, removing but keeping text");
10187 // Remove the HTML link but keep the text
10188 $link_text = $link_match[1];
10189 $cleaned_response = preg_replace(
10190 '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
10191 $link_text,
10192 $cleaned_response
10193 );
10194 }
10195 // Otherwise just remove the bare URL
10196 else {
10197 error_log("Removing bare URL");
10198 $cleaned_response = str_replace($found_url, '', $cleaned_response);
10199 }
10200 }
10201 }
10202
10203 // Log summary if any URLs were removed
10204 if ($removed_count > 0) {
10205 error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
10206 } else {
10207 error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
10208 }
10209
10210 // Clean up any double spaces or awkward punctuation left behind
10211 // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
10212 $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
10213 $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
10214
10215 error_log("Final cleaned response: " . $cleaned_response);
10216
10217 return trim($cleaned_response);
10218 }
10219
10220 /**
10221 * AJAX handler to get current chat mode for a session
10222 */
10223 public function mxchat_get_current_chat_mode() {
10224 // Verify nonce for security
10225 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
10226 wp_send_json_error(['message' => 'Invalid nonce']);
10227 wp_die();
10228 }
10229
10230 $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
10231
10232 if (empty($session_id)) {
10233 wp_send_json_error(['message' => 'Session ID missing']);
10234 wp_die();
10235 }
10236
10237 // Get the current chat mode for this session
10238 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
10239
10240 wp_send_json_success([
10241 'chat_mode' => $chat_mode
10242 ]);
10243 wp_die();
10244 }
10245
10246
10247
10248 }
10249 ?>
10250